Compare commits
11 Commits
3eb3d977f9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cc16ab135 | |||
|
|
a8315d03fd | ||
|
|
ff1c29c66a | ||
|
|
209b6d9f18 | ||
|
|
376ac1dec9 | ||
| 00024cdc0e | |||
|
|
ce353f8b49 | ||
|
|
d4fbdf9273 | ||
|
|
c68a6a7d01 | ||
|
|
948fe591dc | ||
|
|
e5abcced74 |
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
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -98,4 +98,4 @@ frontend/dist/
|
|||||||
!.specify/templates/
|
!.specify/templates/
|
||||||
!.specify/memory/
|
!.specify/memory/
|
||||||
|
|
||||||
.direnv/
|
.direnv/backend/.env
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
from logging.config import fileConfig
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from logging.config import fileConfig
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import engine_from_config
|
from sqlalchemy import engine_from_config, pool
|
||||||
from sqlalchemy import pool
|
|
||||||
|
|
||||||
from alembic import context
|
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.repository import UserRepository
|
||||||
from app.auth.schemas import TokenResponse, UserCreate, UserLogin, UserResponse
|
from app.auth.schemas import TokenResponse, UserCreate, UserLogin, UserResponse
|
||||||
from app.auth.security import validate_password_strength, verify_password
|
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
|
from app.database.models.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
@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.
|
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)
|
@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.
|
Login user and return JWT token.
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.boards.repository import BoardRepository
|
from app.boards.repository import BoardRepository
|
||||||
from app.boards.schemas import BoardCreate, BoardDetail, BoardSummary, BoardUpdate, ViewportStateUpdate
|
from app.boards.schemas import BoardCreate, BoardDetail, BoardSummary, BoardUpdate, ViewportStateUpdate
|
||||||
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
|
from app.database.models.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/boards", tags=["boards"])
|
router = APIRouter(prefix="/boards", tags=["boards"])
|
||||||
@@ -18,7 +18,7 @@ router = APIRouter(prefix="/boards", tags=["boards"])
|
|||||||
def create_board(
|
def create_board(
|
||||||
board_data: BoardCreate,
|
board_data: BoardCreate,
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
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.
|
Create a new board.
|
||||||
@@ -45,7 +45,7 @@ def create_board(
|
|||||||
@router.get("", response_model=dict)
|
@router.get("", response_model=dict)
|
||||||
def list_boards(
|
def list_boards(
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
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,
|
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||||
offset: Annotated[int, Query(ge=0)] = 0,
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
):
|
):
|
||||||
@@ -77,7 +77,7 @@ def list_boards(
|
|||||||
def get_board(
|
def get_board(
|
||||||
board_id: UUID,
|
board_id: UUID,
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
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.
|
Get board details by ID.
|
||||||
@@ -111,7 +111,7 @@ def update_board(
|
|||||||
board_id: UUID,
|
board_id: UUID,
|
||||||
board_data: BoardUpdate,
|
board_data: BoardUpdate,
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db_sync)],
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update board metadata.
|
Update board metadata.
|
||||||
@@ -157,7 +157,7 @@ def update_viewport(
|
|||||||
board_id: UUID,
|
board_id: UUID,
|
||||||
viewport_data: ViewportStateUpdate,
|
viewport_data: ViewportStateUpdate,
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db_sync)],
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update board viewport state only (optimized for frequent updates).
|
Update board viewport state only (optimized for frequent updates).
|
||||||
@@ -198,7 +198,7 @@ def update_viewport(
|
|||||||
def delete_board(
|
def delete_board(
|
||||||
board_id: UUID,
|
board_id: UUID,
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
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).
|
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 uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth.jwt import get_current_user
|
from app.core.deps import get_current_user_async, get_db
|
||||||
from app.core.deps import get_db
|
|
||||||
from app.database.models.board import Board
|
from app.database.models.board import Board
|
||||||
from app.database.models.user import User
|
from app.database.models.user import User
|
||||||
from app.images.processing import generate_thumbnails
|
from app.images.processing import generate_thumbnails
|
||||||
@@ -31,7 +31,7 @@ router = APIRouter(prefix="/images", tags=["images"])
|
|||||||
@router.post("/upload", response_model=ImageUploadResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/upload", response_model=ImageUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def upload_image(
|
async def upload_image(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user_async),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -65,7 +65,7 @@ async def upload_image(
|
|||||||
checksum = calculate_checksum(contents)
|
checksum = calculate_checksum(contents)
|
||||||
|
|
||||||
# Create metadata
|
# 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
|
# Create database record
|
||||||
repo = ImageRepository(db)
|
repo = ImageRepository(db)
|
||||||
@@ -77,7 +77,7 @@ async def upload_image(
|
|||||||
mime_type=mime_type,
|
mime_type=mime_type,
|
||||||
width=width,
|
width=width,
|
||||||
height=height,
|
height=height,
|
||||||
metadata=metadata,
|
image_metadata=image_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
return image
|
return image
|
||||||
@@ -86,7 +86,7 @@ async def upload_image(
|
|||||||
@router.post("/upload-zip", response_model=list[ImageUploadResponse])
|
@router.post("/upload-zip", response_model=list[ImageUploadResponse])
|
||||||
async def upload_zip(
|
async def upload_zip(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user_async),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -121,7 +121,7 @@ async def upload_zip(
|
|||||||
checksum = calculate_checksum(contents)
|
checksum = calculate_checksum(contents)
|
||||||
|
|
||||||
# Create metadata
|
# Create metadata
|
||||||
metadata = {
|
img_metadata = {
|
||||||
"format": mime_type.split("/")[1],
|
"format": mime_type.split("/")[1],
|
||||||
"checksum": checksum,
|
"checksum": checksum,
|
||||||
"thumbnails": thumbnail_paths,
|
"thumbnails": thumbnail_paths,
|
||||||
@@ -136,7 +136,7 @@ async def upload_zip(
|
|||||||
mime_type=mime_type,
|
mime_type=mime_type,
|
||||||
width=width,
|
width=width,
|
||||||
height=height,
|
height=height,
|
||||||
metadata=metadata,
|
image_metadata=img_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
uploaded_images.append(image)
|
uploaded_images.append(image)
|
||||||
@@ -156,7 +156,7 @@ async def upload_zip(
|
|||||||
async def get_image_library(
|
async def get_image_library(
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 50,
|
page_size: int = 50,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user_async),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -174,10 +174,10 @@ async def get_image_library(
|
|||||||
@router.get("/{image_id}", response_model=ImageResponse)
|
@router.get("/{image_id}", response_model=ImageResponse)
|
||||||
async def get_image(
|
async def get_image(
|
||||||
image_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),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get image by ID."""
|
"""Get image metadata by ID."""
|
||||||
repo = ImageRepository(db)
|
repo = ImageRepository(db)
|
||||||
image = await repo.get_image_by_id(image_id)
|
image = await repo.get_image_by_id(image_id)
|
||||||
|
|
||||||
@@ -191,10 +191,67 @@ async def get_image(
|
|||||||
return 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)
|
@router.delete("/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_image(
|
async def delete_image(
|
||||||
image_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),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -224,8 +281,8 @@ async def delete_image(
|
|||||||
from app.images.upload import delete_image_from_storage
|
from app.images.upload import delete_image_from_storage
|
||||||
|
|
||||||
await delete_image_from_storage(image.storage_path)
|
await delete_image_from_storage(image.storage_path)
|
||||||
if "thumbnails" in image.metadata:
|
if "thumbnails" in image.image_metadata:
|
||||||
await delete_thumbnails(image.metadata["thumbnails"])
|
await delete_thumbnails(image.image_metadata["thumbnails"])
|
||||||
|
|
||||||
# Delete from database
|
# Delete from database
|
||||||
await repo.delete_image(image_id)
|
await repo.delete_image(image_id)
|
||||||
@@ -235,7 +292,7 @@ async def delete_image(
|
|||||||
async def add_image_to_board(
|
async def add_image_to_board(
|
||||||
board_id: UUID,
|
board_id: UUID,
|
||||||
data: BoardImageCreate,
|
data: BoardImageCreate,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user_async),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -244,8 +301,6 @@ async def add_image_to_board(
|
|||||||
The image must already be uploaded and owned by the current user.
|
The image must already be uploaded and owned by the current user.
|
||||||
"""
|
"""
|
||||||
# Verify board ownership
|
# Verify board ownership
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
board_result = await db.execute(select(Board).where(Board.id == board_id))
|
board_result = await db.execute(select(Board).where(Board.id == board_id))
|
||||||
board = board_result.scalar_one_or_none()
|
board = board_result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -285,7 +340,7 @@ async def update_board_image(
|
|||||||
board_id: UUID,
|
board_id: UUID,
|
||||||
image_id: UUID,
|
image_id: UUID,
|
||||||
data: BoardImageUpdate,
|
data: BoardImageUpdate,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user_async),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -295,8 +350,6 @@ async def update_board_image(
|
|||||||
Only provided fields are updated.
|
Only provided fields are updated.
|
||||||
"""
|
"""
|
||||||
# Verify board ownership
|
# Verify board ownership
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
board_result = await db.execute(select(Board).where(Board.id == board_id))
|
board_result = await db.execute(select(Board).where(Board.id == board_id))
|
||||||
board = board_result.scalar_one_or_none()
|
board = board_result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -330,7 +383,7 @@ async def update_board_image(
|
|||||||
async def remove_image_from_board(
|
async def remove_image_from_board(
|
||||||
board_id: UUID,
|
board_id: UUID,
|
||||||
image_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),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -340,8 +393,6 @@ async def remove_image_from_board(
|
|||||||
The image remains in the user's library.
|
The image remains in the user's library.
|
||||||
"""
|
"""
|
||||||
# Verify board ownership
|
# Verify board ownership
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
board_result = await db.execute(select(Board).where(Board.id == board_id))
|
board_result = await db.execute(select(Board).where(Board.id == board_id))
|
||||||
board = board_result.scalar_one_or_none()
|
board = board_result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -363,7 +414,7 @@ async def remove_image_from_board(
|
|||||||
async def bulk_update_board_images(
|
async def bulk_update_board_images(
|
||||||
board_id: UUID,
|
board_id: UUID,
|
||||||
data: BulkImageUpdate,
|
data: BulkImageUpdate,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user_async),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -372,8 +423,6 @@ async def bulk_update_board_images(
|
|||||||
Applies the same changes to all specified images. Useful for multi-selection operations.
|
Applies the same changes to all specified images. Useful for multi-selection operations.
|
||||||
"""
|
"""
|
||||||
# Verify board ownership
|
# Verify board ownership
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
board_result = await db.execute(select(Board).where(Board.id == board_id))
|
board_result = await db.execute(select(Board).where(Board.id == board_id))
|
||||||
board = board_result.scalar_one_or_none()
|
board = board_result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -439,7 +488,7 @@ async def bulk_update_board_images(
|
|||||||
@router.get("/boards/{board_id}/images", response_model=list[BoardImageResponse])
|
@router.get("/boards/{board_id}/images", response_model=list[BoardImageResponse])
|
||||||
async def get_board_images(
|
async def get_board_images(
|
||||||
board_id: UUID,
|
board_id: UUID,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user_async),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -448,8 +497,6 @@ async def get_board_images(
|
|||||||
Used for loading board contents in the canvas.
|
Used for loading board contents in the canvas.
|
||||||
"""
|
"""
|
||||||
# Verify board access (owner or shared link - for now just owner)
|
# 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_result = await db.execute(select(Board).where(Board.id == board_id))
|
||||||
board = board_result.scalar_one_or_none()
|
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."""
|
"""JWT token generation and validation."""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from jose import JWTError, jwt
|
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
|
Encoded JWT token string
|
||||||
"""
|
"""
|
||||||
if expires_delta:
|
if expires_delta:
|
||||||
expire = datetime.utcnow() + expires_delta
|
expire = datetime.now(UTC) + expires_delta
|
||||||
else:
|
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)
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
return encoded_jwt
|
return encoded_jwt
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.database.models.board import Board
|
from app.database.models.board import Board
|
||||||
from app.database.models.board_image import BoardImage
|
from app.database.models.board_image import BoardImage
|
||||||
|
from app.database.models.group import Group
|
||||||
|
|
||||||
|
|
||||||
class BoardRepository:
|
class BoardRepository:
|
||||||
@@ -195,3 +196,213 @@ class BoardRepository:
|
|||||||
count = self.db.execute(stmt).scalar_one()
|
count = self.db.execute(stmt).scalar_one()
|
||||||
|
|
||||||
return count > 0
|
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
|
||||||
|
|||||||
@@ -74,3 +74,81 @@ class BoardDetail(BaseModel):
|
|||||||
if isinstance(v, dict):
|
if isinstance(v, dict):
|
||||||
return ViewportState(**v)
|
return ViewportState(**v)
|
||||||
return 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")
|
@field_validator("CORS_ORIGINS", mode="before")
|
||||||
@classmethod
|
@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."""
|
"""Parse CORS origins from string or list."""
|
||||||
if isinstance(v, str):
|
if isinstance(v, str):
|
||||||
return [origin.strip() for origin in v.split(",")]
|
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
|
# File Upload
|
||||||
MAX_FILE_SIZE: int = 52428800 # 50MB
|
MAX_FILE_SIZE: int = 52428800 # 50MB
|
||||||
|
|||||||
@@ -5,24 +5,48 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
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.auth.jwt import decode_access_token
|
||||||
|
from app.core.config import settings
|
||||||
from app.database.models.user import User
|
from app.database.models.user import User
|
||||||
from app.database.session import get_db
|
from app.database.session import get_db
|
||||||
|
|
||||||
# Database session dependency
|
# Sync engine for synchronous endpoints
|
||||||
DatabaseSession = Annotated[Session, Depends(get_db)]
|
_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 scheme for JWT Bearer token
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
def get_current_user(
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db)
|
credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db_sync)
|
||||||
) -> User:
|
) -> User:
|
||||||
"""
|
"""
|
||||||
Get current authenticated user from JWT token.
|
Get current authenticated user from JWT token (synchronous version).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
credentials: HTTP Authorization Bearer token
|
credentials: HTTP Authorization Bearer token
|
||||||
@@ -63,7 +87,7 @@ def get_current_user(
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
) from None
|
) from None
|
||||||
|
|
||||||
# Get user from database
|
# Get user from database (sync)
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
|
||||||
if user is None:
|
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")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is deactivated")
|
||||||
|
|
||||||
return user
|
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
|
||||||
|
|||||||
@@ -91,6 +91,27 @@ class StorageClient:
|
|||||||
logger.error(f"Failed to download file {object_name}: {e}")
|
logger.error(f"Failed to download file {object_name}: {e}")
|
||||||
raise
|
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:
|
def delete_file(self, object_name: str) -> None:
|
||||||
"""Delete file from MinIO.
|
"""Delete file from MinIO.
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
"""Base model for all database models."""
|
"""Base model for all database models."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import Column, DateTime
|
from sqlalchemy import Column, DateTime, func
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import DeclarativeBase, declared_attr
|
from sqlalchemy.orm import DeclarativeBase, declared_attr
|
||||||
|
|
||||||
@@ -22,7 +21,7 @@ class Base(DeclarativeBase):
|
|||||||
|
|
||||||
# Common columns for all models
|
# Common columns for all models
|
||||||
id: Any = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
|
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]:
|
def dict(self) -> dict[str, Any]:
|
||||||
"""Convert model to dictionary."""
|
"""Convert model to dictionary."""
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID, uuid4
|
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 JSONB
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
@@ -13,6 +13,7 @@ from app.database.base import Base
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.database.models.board_image import BoardImage
|
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.group import Group
|
||||||
from app.database.models.share_link import ShareLink
|
from app.database.models.share_link import ShareLink
|
||||||
from app.database.models.user import User
|
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},
|
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(
|
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)
|
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ class Board(Base):
|
|||||||
share_links: Mapped[list["ShareLink"]] = relationship(
|
share_links: Mapped[list["ShareLink"]] = relationship(
|
||||||
"ShareLink", back_populates="board", cascade="all, delete-orphan"
|
"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:
|
def __repr__(self) -> str:
|
||||||
"""String representation of Board."""
|
"""String representation of Board."""
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID, uuid4
|
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 JSONB
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
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
|
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(
|
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
|
# Relationships
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Comment model for board comments."""
|
"""Comment model for board annotations."""
|
||||||
|
|
||||||
import uuid
|
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.dialects.postgresql import JSONB, UUID
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
@@ -11,19 +10,17 @@ from app.database.base import Base
|
|||||||
|
|
||||||
|
|
||||||
class Comment(Base):
|
class Comment(Base):
|
||||||
"""Comment model for viewer comments on shared boards."""
|
"""Comment model representing viewer comments on shared boards."""
|
||||||
|
|
||||||
__tablename__ = "comments"
|
__tablename__ = "comments"
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
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)
|
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False)
|
||||||
share_link_id = Column(
|
share_link_id = Column(UUID(as_uuid=True), ForeignKey("share_links.id", ondelete="SET NULL"), nullable=True)
|
||||||
UUID(as_uuid=True), ForeignKey("share_links.id", ondelete="SET NULL"), nullable=True, index=True
|
|
||||||
)
|
|
||||||
author_name = Column(String(100), nullable=False)
|
author_name = Column(String(100), nullable=False)
|
||||||
content = Column(Text, nullable=False)
|
content = Column(Text, nullable=False)
|
||||||
position = Column(JSONB, nullable=True) # Optional canvas position
|
position = Column(JSONB, nullable=True) # Optional canvas position reference
|
||||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||||
is_deleted = Column(Boolean, nullable=False, default=False)
|
is_deleted = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
@@ -31,4 +28,4 @@ class Comment(Base):
|
|||||||
share_link = relationship("ShareLink", back_populates="comments")
|
share_link = relationship("ShareLink", back_populates="comments")
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
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 typing import TYPE_CHECKING
|
||||||
from uuid import UUID, uuid4
|
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.dialects.postgresql import UUID as PGUUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
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
|
color: Mapped[str] = mapped_column(String(7), nullable=False) # Hex color #RRGGBB
|
||||||
annotation: Mapped[str | None] = mapped_column(Text, nullable=True)
|
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(
|
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
|
# Relationships
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID, uuid4
|
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 JSONB
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
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)
|
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
width: Mapped[int] = mapped_column(Integer, nullable=False)
|
width: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
height: 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)
|
reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|||||||
@@ -1,45 +1,32 @@
|
|||||||
"""ShareLink database model."""
|
"""ShareLink model for board sharing functionality."""
|
||||||
|
|
||||||
from datetime import datetime
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
|
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, func
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from app.database.base import Base
|
from app.database.base import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.database.models.board import Board
|
|
||||||
|
|
||||||
|
|
||||||
class ShareLink(Base):
|
class ShareLink(Base):
|
||||||
"""
|
"""ShareLink model representing shareable board links with permissions."""
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "share_links"
|
__tablename__ = "share_links"
|
||||||
|
|
||||||
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
board_id: Mapped[UUID] = mapped_column(
|
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False)
|
||||||
PGUUID(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'
|
||||||
token: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||||
permission_level: Mapped[str] = mapped_column(String(20), nullable=False) # 'view-only' or 'view-comment'
|
expires_at = Column(DateTime, nullable=True)
|
||||||
|
last_accessed_at = Column(DateTime, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
|
access_count = Column(Integer, nullable=False, default=0)
|
||||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
is_revoked = Column(Boolean, nullable=False, default=False)
|
||||||
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)
|
|
||||||
|
|
||||||
# Relationships
|
# 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:
|
def __repr__(self) -> str:
|
||||||
"""String representation of ShareLink."""
|
return f"<ShareLink(id={self.id}, board_id={self.board_id}, permission={self.permission_level})>"
|
||||||
return f"<ShareLink(id={self.id}, token={self.token[:8]}..., board_id={self.board_id})>"
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""User model for authentication and ownership."""
|
"""User model for authentication and ownership."""
|
||||||
|
|
||||||
import uuid
|
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.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
@@ -18,8 +17,8 @@ class User(Base):
|
|||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||||
password_hash = Column(String(255), nullable=False)
|
password_hash = Column(String(255), nullable=False)
|
||||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||||
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
|
||||||
is_active = Column(Boolean, nullable=False, default=True)
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|||||||
@@ -1,27 +1,33 @@
|
|||||||
"""Database session management."""
|
"""Database session management."""
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
# Create SQLAlchemy engine
|
# Convert sync DATABASE_URL to async (replace postgresql:// with postgresql+asyncpg://)
|
||||||
engine = create_engine(
|
async_database_url = str(settings.DATABASE_URL).replace("postgresql://", "postgresql+asyncpg://")
|
||||||
str(settings.DATABASE_URL),
|
|
||||||
|
# Create async SQLAlchemy engine
|
||||||
|
engine = create_async_engine(
|
||||||
|
async_database_url,
|
||||||
pool_size=settings.DATABASE_POOL_SIZE,
|
pool_size=settings.DATABASE_POOL_SIZE,
|
||||||
max_overflow=settings.DATABASE_MAX_OVERFLOW,
|
max_overflow=settings.DATABASE_MAX_OVERFLOW,
|
||||||
pool_pre_ping=True, # Verify connections before using
|
pool_pre_ping=True, # Verify connections before using
|
||||||
echo=settings.DEBUG, # Log SQL queries in debug mode
|
echo=settings.DEBUG, # Log SQL queries in debug mode
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create session factory
|
# Create async session factory
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(
|
||||||
|
bind=engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
autocommit=False,
|
||||||
|
autoflush=False,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
async def get_db():
|
||||||
"""Dependency for getting database session."""
|
"""Dependency for getting async database session."""
|
||||||
db = SessionLocal()
|
async with SessionLocal() as session:
|
||||||
try:
|
yield session
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|||||||
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,
|
mime_type: str,
|
||||||
width: int,
|
width: int,
|
||||||
height: int,
|
height: int,
|
||||||
metadata: dict,
|
image_metadata: dict,
|
||||||
) -> Image:
|
) -> Image:
|
||||||
"""
|
"""Create new image record."""
|
||||||
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
|
|
||||||
"""
|
|
||||||
image = Image(
|
image = Image(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
filename=filename,
|
filename=filename,
|
||||||
@@ -52,7 +37,7 @@ class ImageRepository:
|
|||||||
mime_type=mime_type,
|
mime_type=mime_type,
|
||||||
width=width,
|
width=width,
|
||||||
height=height,
|
height=height,
|
||||||
metadata=metadata,
|
image_metadata=image_metadata,
|
||||||
)
|
)
|
||||||
self.db.add(image)
|
self.db.add(image)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
@@ -60,52 +45,27 @@ class ImageRepository:
|
|||||||
return image
|
return image
|
||||||
|
|
||||||
async def get_image_by_id(self, image_id: UUID) -> Image | None:
|
async def get_image_by_id(self, image_id: UUID) -> Image | None:
|
||||||
"""
|
"""Get image by ID."""
|
||||||
Get image by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_id: Image ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Image instance or None
|
|
||||||
"""
|
|
||||||
result = await self.db.execute(select(Image).where(Image.id == image_id))
|
result = await self.db.execute(select(Image).where(Image.id == image_id))
|
||||||
return result.scalar_one_or_none()
|
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]:
|
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:
|
# Get total count efficiently
|
||||||
user_id: User ID
|
count_result = await self.db.execute(select(func.count(Image.id)).where(Image.user_id == user_id))
|
||||||
limit: Maximum number of images to return
|
total = count_result.scalar_one()
|
||||||
offset: Number of images to skip
|
|
||||||
|
|
||||||
Returns:
|
# Get paginated images
|
||||||
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
|
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(Image).where(Image.user_id == user_id).order_by(Image.created_at.desc()).limit(limit).offset(offset)
|
select(Image).where(Image.user_id == user_id).order_by(Image.created_at.desc()).limit(limit).offset(offset)
|
||||||
)
|
)
|
||||||
images = result.scalars().all()
|
images = result.scalars().all()
|
||||||
|
|
||||||
return images, total
|
return images, total
|
||||||
|
|
||||||
async def delete_image(self, image_id: UUID) -> bool:
|
async def delete_image(self, image_id: UUID) -> bool:
|
||||||
"""
|
"""Delete image record."""
|
||||||
Delete image record.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_id: Image ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if deleted, False if not found
|
|
||||||
"""
|
|
||||||
image = await self.get_image_by_id(image_id)
|
image = await self.get_image_by_id(image_id)
|
||||||
if not image:
|
if not image:
|
||||||
return False
|
return False
|
||||||
@@ -115,27 +75,14 @@ class ImageRepository:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
async def increment_reference_count(self, image_id: UUID) -> None:
|
async def increment_reference_count(self, image_id: UUID) -> None:
|
||||||
"""
|
"""Increment reference count for image."""
|
||||||
Increment reference count for image.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_id: Image ID
|
|
||||||
"""
|
|
||||||
image = await self.get_image_by_id(image_id)
|
image = await self.get_image_by_id(image_id)
|
||||||
if image:
|
if image:
|
||||||
image.reference_count += 1
|
image.reference_count += 1
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
|
|
||||||
async def decrement_reference_count(self, image_id: UUID) -> int:
|
async def decrement_reference_count(self, image_id: UUID) -> int:
|
||||||
"""
|
"""Decrement reference count for image."""
|
||||||
Decrement reference count for image.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_id: Image ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
New reference count
|
|
||||||
"""
|
|
||||||
image = await self.get_image_by_id(image_id)
|
image = await self.get_image_by_id(image_id)
|
||||||
if image and image.reference_count > 0:
|
if image and image.reference_count > 0:
|
||||||
image.reference_count -= 1
|
image.reference_count -= 1
|
||||||
@@ -151,19 +98,7 @@ class ImageRepository:
|
|||||||
transformations: dict,
|
transformations: dict,
|
||||||
z_order: int = 0,
|
z_order: int = 0,
|
||||||
) -> BoardImage:
|
) -> BoardImage:
|
||||||
"""
|
"""Add image to board."""
|
||||||
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
|
|
||||||
"""
|
|
||||||
board_image = BoardImage(
|
board_image = BoardImage(
|
||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
image_id=image_id,
|
image_id=image_id,
|
||||||
@@ -181,35 +116,50 @@ class ImageRepository:
|
|||||||
return board_image
|
return board_image
|
||||||
|
|
||||||
async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]:
|
async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]:
|
||||||
"""
|
"""Get all images for a board, ordered by z-order."""
|
||||||
Get all images for a board, ordered by z-order.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
board_id: Board ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of BoardImage instances
|
|
||||||
"""
|
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(BoardImage).where(BoardImage.board_id == board_id).order_by(BoardImage.z_order.asc())
|
select(BoardImage).where(BoardImage.board_id == board_id).order_by(BoardImage.z_order.asc())
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
async def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool:
|
async def get_board_image(self, board_id: UUID, image_id: UUID) -> BoardImage | None:
|
||||||
"""
|
"""Get a specific board image."""
|
||||||
Remove image from board.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
board_id: Board ID
|
|
||||||
image_id: Image ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if removed, False if not found
|
|
||||||
"""
|
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
|
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:
|
if not board_image:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -26,13 +26,14 @@ class ImageUploadResponse(BaseModel):
|
|||||||
mime_type: str
|
mime_type: str
|
||||||
width: int
|
width: int
|
||||||
height: int
|
height: int
|
||||||
metadata: dict[str, Any]
|
metadata: dict[str, Any] = Field(..., alias="image_metadata")
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
"""Pydantic config."""
|
"""Pydantic config."""
|
||||||
|
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
populate_by_name = True
|
||||||
|
|
||||||
|
|
||||||
class ImageResponse(BaseModel):
|
class ImageResponse(BaseModel):
|
||||||
@@ -46,7 +47,7 @@ class ImageResponse(BaseModel):
|
|||||||
mime_type: str
|
mime_type: str
|
||||||
width: int
|
width: int
|
||||||
height: int
|
height: int
|
||||||
metadata: dict[str, Any]
|
metadata: dict[str, Any] = Field(..., alias="image_metadata")
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
reference_count: int
|
reference_count: int
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ class ImageResponse(BaseModel):
|
|||||||
"""Pydantic config."""
|
"""Pydantic config."""
|
||||||
|
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
populate_by_name = True
|
||||||
|
|
||||||
|
|
||||||
class BoardImageCreate(BaseModel):
|
class BoardImageCreate(BaseModel):
|
||||||
|
|||||||
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"
|
||||||
@@ -5,7 +5,7 @@ import logging
|
|||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import JSONResponse
|
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.config import settings
|
||||||
from app.core.errors import WebRefException
|
from app.core.errors import WebRefException
|
||||||
from app.core.logging import setup_logging
|
from app.core.logging import setup_logging
|
||||||
@@ -84,7 +84,12 @@ async def root():
|
|||||||
# API routers
|
# API routers
|
||||||
app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}")
|
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(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(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")
|
@app.on_event("startup")
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Integration tests for authentication endpoints."""
|
"""Integration tests for authentication endpoints."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
"""Integration tests for bulk image operations."""
|
"""Integration tests for bulk image operations."""
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from app.database.models.user import User
|
|
||||||
from app.database.models.board import Board
|
from app.database.models.board import Board
|
||||||
from app.database.models.image import Image
|
|
||||||
from app.database.models.board_image import BoardImage
|
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
|
@pytest.mark.asyncio
|
||||||
@@ -26,7 +27,7 @@ async def test_bulk_update_position_delta(client: AsyncClient, test_user: User,
|
|||||||
# Create images
|
# Create images
|
||||||
images = []
|
images = []
|
||||||
board_images = []
|
board_images = []
|
||||||
|
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
image = Image(
|
image = Image(
|
||||||
id=uuid4(),
|
id=uuid4(),
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
"""Integration tests for image deletion endpoints."""
|
"""Integration tests for image deletion endpoints."""
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from app.database.models.user import User
|
|
||||||
from app.database.models.board import Board
|
from app.database.models.board import Board
|
||||||
from app.database.models.image import Image
|
|
||||||
from app.database.models.board_image import BoardImage
|
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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
"""Integration tests for image position update endpoint."""
|
"""Integration tests for image position update endpoint."""
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from app.database.models.user import User
|
|
||||||
from app.database.models.board import Board
|
from app.database.models.board import Board
|
||||||
from app.database.models.image import Image
|
|
||||||
from app.database.models.board_image import BoardImage
|
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
|
@pytest.mark.asyncio
|
||||||
@@ -441,11 +442,11 @@ async def test_update_preserves_other_fields(client: AsyncClient, test_user: Use
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
# Position should be updated
|
# Position should be updated
|
||||||
assert data["position"]["x"] == 200
|
assert data["position"]["x"] == 200
|
||||||
assert data["position"]["y"] == 200
|
assert data["position"]["y"] == 200
|
||||||
|
|
||||||
# Other fields should be preserved
|
# Other fields should be preserved
|
||||||
assert data["transformations"]["scale"] == 1.5
|
assert data["transformations"]["scale"] == 1.5
|
||||||
assert data["transformations"]["rotation"] == 45
|
assert data["transformations"]["rotation"] == 45
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Integration tests for image upload endpoints."""
|
"""Integration tests for image upload endpoints."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import status
|
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 datetime import datetime, timedelta
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import pytest
|
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
|
|
||||||
from app.auth.jwt import create_access_token, decode_access_token
|
from app.auth.jwt import create_access_token, decode_access_token
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Unit tests for password hashing and validation."""
|
"""Unit tests for password hashing and validation."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.auth.security import hash_password, validate_password_strength, verify_password
|
from app.auth.security import hash_password, validate_password_strength, verify_password
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Pytest configuration and fixtures for all tests."""
|
"""Pytest configuration and fixtures for all tests."""
|
||||||
|
|
||||||
import os
|
from collections.abc import Generator
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -105,3 +104,106 @@ def test_user_data_no_uppercase() -> dict:
|
|||||||
"""
|
"""
|
||||||
return {"email": "test@example.com", "password": "testpassword123"}
|
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
|
import io
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
|
||||||
from PIL import Image as PILImage
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
from app.images.processing import generate_thumbnails
|
from app.images.processing import generate_thumbnails
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ def test_transformation_scale_bounds():
|
|||||||
"""Test scale bounds validation."""
|
"""Test scale bounds validation."""
|
||||||
# Valid scales
|
# Valid scales
|
||||||
valid_scales = [0.01, 0.5, 1.0, 5.0, 10.0]
|
valid_scales = [0.01, 0.5, 1.0, 5.0, 10.0]
|
||||||
|
|
||||||
for scale in valid_scales:
|
for scale in valid_scales:
|
||||||
data = BoardImageUpdate(transformations={"scale": scale})
|
data = BoardImageUpdate(transformations={"scale": scale})
|
||||||
assert data.transformations["scale"] == scale
|
assert data.transformations["scale"] == scale
|
||||||
@@ -54,7 +54,7 @@ def test_transformation_rotation_bounds():
|
|||||||
"""Test rotation bounds (any value allowed, normalized client-side)."""
|
"""Test rotation bounds (any value allowed, normalized client-side)."""
|
||||||
# Various rotation values
|
# Various rotation values
|
||||||
rotations = [0, 45, 90, 180, 270, 360, 450, -90]
|
rotations = [0, 45, 90, 180, 270, 360, 450, -90]
|
||||||
|
|
||||||
for rotation in rotations:
|
for rotation in rotations:
|
||||||
data = BoardImageUpdate(transformations={"rotation": rotation})
|
data = BoardImageUpdate(transformations={"rotation": rotation})
|
||||||
assert data.transformations["rotation"] == rotation
|
assert data.transformations["rotation"] == rotation
|
||||||
@@ -64,7 +64,7 @@ def test_transformation_opacity_bounds():
|
|||||||
"""Test opacity bounds."""
|
"""Test opacity bounds."""
|
||||||
# Valid opacity values
|
# Valid opacity values
|
||||||
valid_opacities = [0.0, 0.25, 0.5, 0.75, 1.0]
|
valid_opacities = [0.0, 0.25, 0.5, 0.75, 1.0]
|
||||||
|
|
||||||
for opacity in valid_opacities:
|
for opacity in valid_opacities:
|
||||||
data = BoardImageUpdate(transformations={"opacity": opacity})
|
data = BoardImageUpdate(transformations={"opacity": opacity})
|
||||||
assert data.transformations["opacity"] == opacity
|
assert data.transformations["opacity"] == opacity
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Tests for file validation."""
|
"""Tests for file validation."""
|
||||||
|
|
||||||
import io
|
from unittest.mock import AsyncMock
|
||||||
from unittest.mock import AsyncMock, Mock
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException, UploadFile
|
from fastapi import HTTPException, UploadFile
|
||||||
|
|||||||
76
flake.nix
76
flake.nix
@@ -31,7 +31,8 @@
|
|||||||
alembic
|
alembic
|
||||||
pydantic
|
pydantic
|
||||||
pydantic-settings # Settings management
|
pydantic-settings # Settings management
|
||||||
psycopg2 # PostgreSQL driver
|
psycopg2 # PostgreSQL driver (sync)
|
||||||
|
asyncpg # PostgreSQL driver (async)
|
||||||
# Auth & Security
|
# Auth & Security
|
||||||
python-jose
|
python-jose
|
||||||
passlib
|
passlib
|
||||||
@@ -88,6 +89,7 @@
|
|||||||
# Development tools
|
# Development tools
|
||||||
git
|
git
|
||||||
direnv
|
direnv
|
||||||
|
tmux
|
||||||
];
|
];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
@@ -105,6 +107,7 @@
|
|||||||
echo " Status: ./scripts/dev-services.sh status"
|
echo " Status: ./scripts/dev-services.sh status"
|
||||||
echo ""
|
echo ""
|
||||||
echo "📚 Quick Commands:"
|
echo "📚 Quick Commands:"
|
||||||
|
echo " Dev (tmux): nix run .#dev"
|
||||||
echo " Backend: cd backend && uvicorn app.main:app --reload"
|
echo " Backend: cd backend && uvicorn app.main:app --reload"
|
||||||
echo " Frontend: cd frontend && npm run dev"
|
echo " Frontend: cd frontend && npm run dev"
|
||||||
echo " Database: psql -h localhost -U webref webref"
|
echo " Database: psql -h localhost -U webref webref"
|
||||||
@@ -131,6 +134,7 @@
|
|||||||
type = "app";
|
type = "app";
|
||||||
program = "${pkgs.writeShellScript "help" ''
|
program = "${pkgs.writeShellScript "help" ''
|
||||||
echo "Available commands:"
|
echo "Available commands:"
|
||||||
|
echo " nix run .#dev - Start backend + frontend in tmux"
|
||||||
echo " nix run .#lint - Run all linting checks"
|
echo " nix run .#lint - Run all linting checks"
|
||||||
echo " nix run .#lint-backend - Run backend linting only"
|
echo " nix run .#lint-backend - Run backend linting only"
|
||||||
echo " nix run .#lint-frontend - Run frontend 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
|
# Unified linting - calls both backend and frontend lints
|
||||||
lint = {
|
lint = {
|
||||||
type = "app";
|
type = "app";
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export interface ApiError {
|
|||||||
status_code: number;
|
status_code: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiRequestOptions extends RequestInit {
|
||||||
|
skipAuth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiClient {
|
export class ApiClient {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
|
|
||||||
@@ -20,16 +24,17 @@ export class ApiClient {
|
|||||||
this.baseUrl = baseUrl;
|
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 { token } = get(authStore);
|
||||||
|
const { skipAuth, ...fetchOptions } = options;
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...((options.headers as Record<string, string>) || {}),
|
...((fetchOptions.headers as Record<string, string>) || {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add authentication token if available
|
// Add authentication token if available and not skipped
|
||||||
if (token) {
|
if (token && !skipAuth) {
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +42,7 @@ export class ApiClient {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...fetchOptions,
|
||||||
headers,
|
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' });
|
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, {
|
return this.request<T>(endpoint, {
|
||||||
...options,
|
...options,
|
||||||
method: 'POST',
|
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, {
|
return this.request<T>(endpoint, {
|
||||||
...options,
|
...options,
|
||||||
method: 'PUT',
|
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, {
|
return this.request<T>(endpoint, {
|
||||||
...options,
|
...options,
|
||||||
method: 'PATCH',
|
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' });
|
return this.request<T>(endpoint, { ...options, method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,18 +132,34 @@ export class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = `${this.baseUrl}${endpoint}`;
|
const url = `${this.baseUrl}${endpoint}`;
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
try {
|
||||||
const error = await response.json();
|
const response = await fetch(url, {
|
||||||
throw error;
|
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
|
* Upload a single image
|
||||||
*/
|
*/
|
||||||
export async function uploadImage(file: File): Promise<Image> {
|
export async function uploadImage(file: File): Promise<Image> {
|
||||||
const formData = new FormData();
|
return await apiClient.uploadFile<Image>('/images/upload', file);
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const response = await apiClient.post<Image>('/images/upload', formData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload multiple images from a ZIP file
|
* Upload multiple images from a ZIP file
|
||||||
*/
|
*/
|
||||||
export async function uploadZip(file: File): Promise<Image[]> {
|
export async function uploadZip(file: File): Promise<Image[]> {
|
||||||
const formData = new FormData();
|
return await apiClient.uploadFile<Image[]>('/images/upload-zip', file);
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const response = await apiClient.post<Image[]>('/images/upload-zip', formData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,3 +87,19 @@ export async function removeImageFromBoard(boardId: string, imageId: string): Pr
|
|||||||
export async function getBoardImages(boardId: string): Promise<BoardImage[]> {
|
export async function getBoardImages(boardId: string): Promise<BoardImage[]> {
|
||||||
return await apiClient.get<BoardImage[]>(`/images/boards/${boardId}/images`);
|
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 -->
|
||||||
@@ -8,9 +8,12 @@
|
|||||||
import { isImageSelected } from '$lib/stores/selection';
|
import { isImageSelected } from '$lib/stores/selection';
|
||||||
import { setupImageDrag } from './interactions/drag';
|
import { setupImageDrag } from './interactions/drag';
|
||||||
import { setupImageSelection } from './interactions/select';
|
import { setupImageSelection } from './interactions/select';
|
||||||
|
import { activeQuality } from '$lib/stores/quality';
|
||||||
|
import { getAdaptiveThumbnailUrl } from '$lib/utils/adaptive-quality';
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
export let id: string; // Board image ID
|
export let id: string; // Board image ID
|
||||||
|
export let imageId: string; // Image UUID for quality-based loading
|
||||||
export let imageUrl: string;
|
export let imageUrl: string;
|
||||||
export let x: number = 0;
|
export let x: number = 0;
|
||||||
export let y: number = 0;
|
export let y: number = 0;
|
||||||
@@ -26,6 +29,7 @@
|
|||||||
// Callbacks
|
// Callbacks
|
||||||
export let onDragEnd: ((id: string, x: number, y: number) => void) | undefined = undefined;
|
export let onDragEnd: ((id: string, x: number, y: number) => void) | undefined = undefined;
|
||||||
export let onSelectionChange: ((id: string, isSelected: boolean) => 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 imageNode: Konva.Image | null = null;
|
||||||
let imageGroup: Konva.Group | null = null;
|
let imageGroup: Konva.Group | null = null;
|
||||||
@@ -33,10 +37,21 @@
|
|||||||
let cleanupDrag: (() => void) | null = null;
|
let cleanupDrag: (() => void) | null = null;
|
||||||
let cleanupSelection: (() => void) | null = null;
|
let cleanupSelection: (() => void) | null = null;
|
||||||
let unsubscribeSelection: (() => void) | null = null;
|
let unsubscribeSelection: (() => void) | null = null;
|
||||||
|
let isFullResolution: boolean = false;
|
||||||
|
|
||||||
// Subscribe to selection state for this image
|
// Subscribe to selection state for this image
|
||||||
$: isSelected = isImageSelected(id);
|
$: isSelected = isImageSelected(id);
|
||||||
|
|
||||||
|
// Subscribe to quality changes
|
||||||
|
$: {
|
||||||
|
if (imageId && !isFullResolution) {
|
||||||
|
const newUrl = getAdaptiveThumbnailUrl(imageId);
|
||||||
|
if (imageObj && imageObj.src !== newUrl) {
|
||||||
|
loadImageWithQuality($activeQuality);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!layer) return;
|
if (!layer) return;
|
||||||
|
|
||||||
@@ -70,11 +85,12 @@
|
|||||||
|
|
||||||
imageGroup.add(imageNode);
|
imageGroup.add(imageNode);
|
||||||
|
|
||||||
// Set Z-index
|
// Add to layer first
|
||||||
imageGroup.zIndex(zOrder);
|
|
||||||
|
|
||||||
layer.add(imageGroup);
|
layer.add(imageGroup);
|
||||||
|
|
||||||
|
// Then set Z-index (must have parent first)
|
||||||
|
imageGroup.zIndex(zOrder);
|
||||||
|
|
||||||
// Setup interactions
|
// Setup interactions
|
||||||
cleanupDrag = setupImageDrag(imageGroup, id, undefined, (imageId, newX, newY) => {
|
cleanupDrag = setupImageDrag(imageGroup, id, undefined, (imageId, newX, newY) => {
|
||||||
if (onDragEnd) {
|
if (onDragEnd) {
|
||||||
@@ -94,7 +110,26 @@
|
|||||||
updateSelectionVisual();
|
updateSelectionVisual();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initial draw
|
||||||
layer.batchDraw();
|
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;
|
imageObj.src = imageUrl;
|
||||||
@@ -198,6 +233,38 @@
|
|||||||
export function getImageNode(): Konva.Image | null {
|
export function getImageNode(): Konva.Image | null {
|
||||||
return imageNode;
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- This component doesn't render any DOM, it only manages Konva nodes -->
|
<!-- This component doesn't render any DOM, it only manages Konva nodes -->
|
||||||
|
|||||||
@@ -11,9 +11,15 @@
|
|||||||
import { setupZoomControls } from './controls/zoom';
|
import { setupZoomControls } from './controls/zoom';
|
||||||
import { setupRotateControls } from './controls/rotate';
|
import { setupRotateControls } from './controls/rotate';
|
||||||
import { setupGestureControls } from './gestures';
|
import { setupGestureControls } from './gestures';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
// Board ID for future use (e.g., loading board-specific state)
|
// 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 width: number = 0;
|
||||||
export let height: number = 0;
|
export let height: number = 0;
|
||||||
|
|
||||||
@@ -40,6 +46,13 @@
|
|||||||
layer = new Konva.Layer();
|
layer = new Konva.Layer();
|
||||||
stage.add(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
|
// Set up controls
|
||||||
if (stage) {
|
if (stage) {
|
||||||
cleanupPan = setupPanControls(stage);
|
cleanupPan = setupPanControls(stage);
|
||||||
@@ -48,13 +61,13 @@
|
|||||||
cleanupGestures = setupGestureControls(stage);
|
cleanupGestures = setupGestureControls(stage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to viewport changes
|
// Subscribe to viewport changes (after initial state applied)
|
||||||
unsubscribeViewport = viewport.subscribe((state) => {
|
unsubscribeViewport = viewport.subscribe((state) => {
|
||||||
updateStageTransform(state);
|
updateStageTransform(state);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply initial viewport state
|
// Notify parent that stage is ready
|
||||||
updateStageTransform($viewport);
|
dispatch('ready');
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -78,21 +91,26 @@
|
|||||||
* Update stage transform based on viewport state
|
* Update stage transform based on viewport state
|
||||||
*/
|
*/
|
||||||
function updateStageTransform(state: ViewportState) {
|
function updateStageTransform(state: ViewportState) {
|
||||||
if (!stage) return;
|
if (!stage || !layer) return;
|
||||||
|
|
||||||
// Apply transformations to the stage
|
// Don't apply transforms to the stage itself - it causes rendering issues
|
||||||
stage.position({ x: state.x, y: state.y });
|
// Instead, we'll transform the layer
|
||||||
stage.scale({ x: state.zoom, y: state.zoom });
|
layer.position({ x: state.x, y: state.y });
|
||||||
stage.rotation(state.rotation);
|
layer.scale({ x: state.zoom, y: state.zoom });
|
||||||
|
layer.rotation(state.rotation);
|
||||||
|
|
||||||
|
// Force both layer and stage to redraw
|
||||||
|
layer.batchDraw();
|
||||||
stage.batchDraw();
|
stage.batchDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resize canvas when dimensions change
|
* 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.width(width);
|
||||||
stage.height(height);
|
stage.height(height);
|
||||||
|
layer.batchDraw();
|
||||||
stage.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;
|
||||||
|
}
|
||||||
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ export interface KeyboardShortcutHandlers {
|
|||||||
onPaste?: () => void;
|
onPaste?: () => void;
|
||||||
onUndo?: () => void;
|
onUndo?: () => void;
|
||||||
onRedo?: () => void;
|
onRedo?: () => void;
|
||||||
|
onBringToFront?: () => void;
|
||||||
|
onSendToBack?: () => void;
|
||||||
|
onBringForward?: () => void;
|
||||||
|
onSendBackward?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,6 +133,46 @@ export function setupKeyboardShortcuts(
|
|||||||
}
|
}
|
||||||
return;
|
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
|
// Attach event listener
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
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}`;
|
||||||
|
}
|
||||||
58
frontend/src/lib/canvas/operations/ungroup.ts
Normal file
58
frontend/src/lib/canvas/operations/ungroup.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Ungroup operations
|
||||||
|
* Remove images from groups
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UngroupOptions {
|
||||||
|
onUngroupComplete?: (imageIds: string[], groupId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ungroup images (remove from group)
|
||||||
|
*/
|
||||||
|
export async function ungroupImages(
|
||||||
|
boardId: string,
|
||||||
|
groupId: string,
|
||||||
|
options: UngroupOptions = {}
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { deleteGroup } = await import('$lib/api/groups');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteGroup(boardId, groupId);
|
||||||
|
|
||||||
|
if (options.onUngroupComplete) {
|
||||||
|
// Note: We'd need to track which images were in the group
|
||||||
|
options.onUngroupComplete([], groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to ungroup:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove specific images from group
|
||||||
|
*/
|
||||||
|
export async function removeImagesFromGroup(
|
||||||
|
boardId: string,
|
||||||
|
groupId: string,
|
||||||
|
imageIds: string[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Update board images to remove group_id
|
||||||
|
const { apiClient } = await import('$lib/api/client');
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const imageId of imageIds) {
|
||||||
|
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
|
||||||
|
group_id: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove images from group:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
180
frontend/src/lib/canvas/operations/z-order.ts
Normal file
180
frontend/src/lib/canvas/operations/z-order.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Z-order (layering) operations for canvas images
|
||||||
|
* Controls which images appear in front of or behind others
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Konva from 'konva';
|
||||||
|
|
||||||
|
export interface ZOrderOptions {
|
||||||
|
onZOrderChange?: (imageId: string, newZOrder: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring image to front (highest Z-order)
|
||||||
|
*/
|
||||||
|
export function bringToFront(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
imageId: string,
|
||||||
|
allImages: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
options: ZOrderOptions = {}
|
||||||
|
): void {
|
||||||
|
// Find maximum Z-order
|
||||||
|
let maxZOrder = 0;
|
||||||
|
allImages.forEach((img) => {
|
||||||
|
const zIndex = img.zIndex();
|
||||||
|
if (zIndex > maxZOrder) {
|
||||||
|
maxZOrder = zIndex;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set to max + 1
|
||||||
|
const newZOrder = maxZOrder + 1;
|
||||||
|
image.zIndex(newZOrder);
|
||||||
|
|
||||||
|
image.getLayer()?.batchDraw();
|
||||||
|
|
||||||
|
if (options.onZOrderChange) {
|
||||||
|
options.onZOrderChange(imageId, newZOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send image to back (lowest Z-order)
|
||||||
|
*/
|
||||||
|
export function sendToBack(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
imageId: string,
|
||||||
|
options: ZOrderOptions = {}
|
||||||
|
): void {
|
||||||
|
image.zIndex(0);
|
||||||
|
image.getLayer()?.batchDraw();
|
||||||
|
|
||||||
|
if (options.onZOrderChange) {
|
||||||
|
options.onZOrderChange(imageId, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring image forward (increase Z-order by 1)
|
||||||
|
*/
|
||||||
|
export function bringForward(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
imageId: string,
|
||||||
|
options: ZOrderOptions = {}
|
||||||
|
): void {
|
||||||
|
const currentZIndex = image.zIndex();
|
||||||
|
const newZOrder = currentZIndex + 1;
|
||||||
|
|
||||||
|
image.zIndex(newZOrder);
|
||||||
|
image.getLayer()?.batchDraw();
|
||||||
|
|
||||||
|
if (options.onZOrderChange) {
|
||||||
|
options.onZOrderChange(imageId, newZOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send image backward (decrease Z-order by 1)
|
||||||
|
*/
|
||||||
|
export function sendBackward(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
imageId: string,
|
||||||
|
options: ZOrderOptions = {}
|
||||||
|
): void {
|
||||||
|
const currentZIndex = image.zIndex();
|
||||||
|
const newZOrder = Math.max(0, currentZIndex - 1);
|
||||||
|
|
||||||
|
image.zIndex(newZOrder);
|
||||||
|
image.getLayer()?.batchDraw();
|
||||||
|
|
||||||
|
if (options.onZOrderChange) {
|
||||||
|
options.onZOrderChange(imageId, newZOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set specific Z-order
|
||||||
|
*/
|
||||||
|
export function setZOrder(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
imageId: string,
|
||||||
|
zOrder: number,
|
||||||
|
options: ZOrderOptions = {}
|
||||||
|
): void {
|
||||||
|
image.zIndex(Math.max(0, zOrder));
|
||||||
|
image.getLayer()?.batchDraw();
|
||||||
|
|
||||||
|
if (options.onZOrderChange) {
|
||||||
|
options.onZOrderChange(imageId, zOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current Z-order
|
||||||
|
*/
|
||||||
|
export function getZOrder(image: Konva.Image | Konva.Group): number {
|
||||||
|
return image.zIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk bring to front (multiple images)
|
||||||
|
*/
|
||||||
|
export function bulkBringToFront(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[],
|
||||||
|
allImages: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
options: ZOrderOptions = {}
|
||||||
|
): void {
|
||||||
|
// Find maximum Z-order
|
||||||
|
let maxZOrder = 0;
|
||||||
|
allImages.forEach((img) => {
|
||||||
|
const zIndex = img.zIndex();
|
||||||
|
if (zIndex > maxZOrder) {
|
||||||
|
maxZOrder = zIndex;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set selected images to top, maintaining relative order
|
||||||
|
selectedIds.forEach((id, index) => {
|
||||||
|
const image = images.get(id);
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
const newZOrder = maxZOrder + 1 + index;
|
||||||
|
image.zIndex(newZOrder);
|
||||||
|
|
||||||
|
if (options.onZOrderChange) {
|
||||||
|
options.onZOrderChange(id, newZOrder);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||||
|
if (firstImage) {
|
||||||
|
firstImage.getLayer()?.batchDraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk send to back (multiple images)
|
||||||
|
*/
|
||||||
|
export function bulkSendToBack(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[],
|
||||||
|
options: ZOrderOptions = {}
|
||||||
|
): void {
|
||||||
|
// Set selected images to bottom, maintaining relative order
|
||||||
|
selectedIds.forEach((id, index) => {
|
||||||
|
const image = images.get(id);
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
image.zIndex(index);
|
||||||
|
|
||||||
|
if (options.onZOrderChange) {
|
||||||
|
options.onZOrderChange(id, index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||||
|
if (firstImage) {
|
||||||
|
firstImage.getLayer()?.batchDraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
145
frontend/src/lib/canvas/slideshow.ts
Normal file
145
frontend/src/lib/canvas/slideshow.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Slideshow mode for automatic image presentation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import type { Writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export interface SlideshowState {
|
||||||
|
isActive: boolean;
|
||||||
|
isPaused: boolean;
|
||||||
|
currentImageId: string | null;
|
||||||
|
imageIds: string[];
|
||||||
|
currentIndex: number;
|
||||||
|
interval: number; // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_INTERVAL = 5; // 5 seconds
|
||||||
|
|
||||||
|
function createSlideshowStore() {
|
||||||
|
const { subscribe, set, update }: Writable<SlideshowState> = writable({
|
||||||
|
isActive: false,
|
||||||
|
isPaused: false,
|
||||||
|
currentImageId: null,
|
||||||
|
imageIds: [],
|
||||||
|
currentIndex: 0,
|
||||||
|
interval: DEFAULT_INTERVAL,
|
||||||
|
});
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function clearTimer() {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimer(state: SlideshowState, nextFn: () => void) {
|
||||||
|
clearTimer();
|
||||||
|
if (state.isActive && !state.isPaused) {
|
||||||
|
timer = setInterval(nextFn, state.interval * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start slideshow.
|
||||||
|
*/
|
||||||
|
start(imageIds: string[], startIndex: number = 0, interval: number = DEFAULT_INTERVAL) {
|
||||||
|
const state = {
|
||||||
|
isActive: true,
|
||||||
|
isPaused: false,
|
||||||
|
imageIds,
|
||||||
|
currentIndex: startIndex,
|
||||||
|
currentImageId: imageIds[startIndex] || null,
|
||||||
|
interval,
|
||||||
|
};
|
||||||
|
set(state);
|
||||||
|
startTimer(state, this.next);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop slideshow.
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
clearTimer();
|
||||||
|
set({
|
||||||
|
isActive: false,
|
||||||
|
isPaused: false,
|
||||||
|
currentImageId: null,
|
||||||
|
imageIds: [],
|
||||||
|
currentIndex: 0,
|
||||||
|
interval: DEFAULT_INTERVAL,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause slideshow.
|
||||||
|
*/
|
||||||
|
pause() {
|
||||||
|
clearTimer();
|
||||||
|
update((state) => ({ ...state, isPaused: true }));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume slideshow.
|
||||||
|
*/
|
||||||
|
resume() {
|
||||||
|
update((state) => {
|
||||||
|
const newState = { ...state, isPaused: false };
|
||||||
|
startTimer(newState, this.next);
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next image.
|
||||||
|
*/
|
||||||
|
next() {
|
||||||
|
update((state) => {
|
||||||
|
const nextIndex = (state.currentIndex + 1) % state.imageIds.length;
|
||||||
|
const newState = {
|
||||||
|
...state,
|
||||||
|
currentIndex: nextIndex,
|
||||||
|
currentImageId: state.imageIds[nextIndex],
|
||||||
|
};
|
||||||
|
if (!state.isPaused) {
|
||||||
|
startTimer(newState, this.next);
|
||||||
|
}
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Previous image.
|
||||||
|
*/
|
||||||
|
previous() {
|
||||||
|
update((state) => {
|
||||||
|
const prevIndex = (state.currentIndex - 1 + state.imageIds.length) % state.imageIds.length;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentIndex: prevIndex,
|
||||||
|
currentImageId: state.imageIds[prevIndex],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set interval.
|
||||||
|
*/
|
||||||
|
setInterval(seconds: number) {
|
||||||
|
update((state) => {
|
||||||
|
const newState = { ...state, interval: seconds };
|
||||||
|
if (state.isActive && !state.isPaused) {
|
||||||
|
startTimer(newState, this.next);
|
||||||
|
}
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const slideshowStore = createSlideshowStore();
|
||||||
126
frontend/src/lib/commands/registry.ts
Normal file
126
frontend/src/lib/commands/registry.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Command registry for command palette.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Command {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
keywords: string[];
|
||||||
|
shortcut?: string;
|
||||||
|
action: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CommandRegistry {
|
||||||
|
private commands: Map<string, Command> = new Map();
|
||||||
|
private recentlyUsed: string[] = [];
|
||||||
|
private readonly MAX_RECENT = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a command.
|
||||||
|
*/
|
||||||
|
register(command: Command): void {
|
||||||
|
this.commands.set(command.id, command);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a command.
|
||||||
|
*/
|
||||||
|
unregister(commandId: string): void {
|
||||||
|
this.commands.delete(commandId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered commands.
|
||||||
|
*/
|
||||||
|
getAllCommands(): Command[] {
|
||||||
|
return Array.from(this.commands.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get command by ID.
|
||||||
|
*/
|
||||||
|
getCommand(commandId: string): Command | undefined {
|
||||||
|
return this.commands.get(commandId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a command.
|
||||||
|
*/
|
||||||
|
async execute(commandId: string): Promise<void> {
|
||||||
|
const command = this.commands.get(commandId);
|
||||||
|
if (!command) {
|
||||||
|
console.error(`Command not found: ${commandId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await command.action();
|
||||||
|
this.markAsUsed(commandId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to execute command ${commandId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark command as recently used.
|
||||||
|
*/
|
||||||
|
private markAsUsed(commandId: string): void {
|
||||||
|
// Remove if already in list
|
||||||
|
this.recentlyUsed = this.recentlyUsed.filter((id) => id !== commandId);
|
||||||
|
|
||||||
|
// Add to front
|
||||||
|
this.recentlyUsed.unshift(commandId);
|
||||||
|
|
||||||
|
// Keep only MAX_RECENT items
|
||||||
|
if (this.recentlyUsed.length > this.MAX_RECENT) {
|
||||||
|
this.recentlyUsed = this.recentlyUsed.slice(0, this.MAX_RECENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist to localStorage
|
||||||
|
this.saveRecentlyUsed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recently used commands.
|
||||||
|
*/
|
||||||
|
getRecentlyUsed(): Command[] {
|
||||||
|
return this.recentlyUsed
|
||||||
|
.map((id) => this.commands.get(id))
|
||||||
|
.filter((cmd): cmd is Command => cmd !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save recently used commands to localStorage.
|
||||||
|
*/
|
||||||
|
private saveRecentlyUsed(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem('webref_recent_commands', JSON.stringify(this.recentlyUsed));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save recent commands:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load recently used commands from localStorage.
|
||||||
|
*/
|
||||||
|
loadRecentlyUsed(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('webref_recent_commands');
|
||||||
|
if (saved) {
|
||||||
|
this.recentlyUsed = JSON.parse(saved);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load recent commands:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const commandRegistry = new CommandRegistry();
|
||||||
93
frontend/src/lib/commands/search.ts
Normal file
93
frontend/src/lib/commands/search.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Command search and filtering.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Command } from './registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search commands by query.
|
||||||
|
*
|
||||||
|
* @param commands - Array of commands to search
|
||||||
|
* @param query - Search query
|
||||||
|
* @returns Filtered and ranked commands
|
||||||
|
*/
|
||||||
|
export function searchCommands(commands: Command[], query: string): Command[] {
|
||||||
|
if (!query || query.trim() === '') {
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
// Score each command
|
||||||
|
const scored = commands
|
||||||
|
.map((cmd) => ({
|
||||||
|
command: cmd,
|
||||||
|
score: calculateScore(cmd, lowerQuery),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
return scored.map((item) => item.command);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate relevance score for a command.
|
||||||
|
*/
|
||||||
|
function calculateScore(command: Command, query: string): number {
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// Exact name match
|
||||||
|
if (command.name.toLowerCase() === query) {
|
||||||
|
score += 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name starts with query
|
||||||
|
if (command.name.toLowerCase().startsWith(query)) {
|
||||||
|
score += 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name contains query
|
||||||
|
if (command.name.toLowerCase().includes(query)) {
|
||||||
|
score += 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description contains query
|
||||||
|
if (command.description.toLowerCase().includes(query)) {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyword match
|
||||||
|
for (const keyword of command.keywords) {
|
||||||
|
if (keyword.toLowerCase() === query) {
|
||||||
|
score += 30;
|
||||||
|
} else if (keyword.toLowerCase().startsWith(query)) {
|
||||||
|
score += 15;
|
||||||
|
} else if (keyword.toLowerCase().includes(query)) {
|
||||||
|
score += 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category match
|
||||||
|
if (command.category.toLowerCase().includes(query)) {
|
||||||
|
score += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group commands by category.
|
||||||
|
*/
|
||||||
|
export function groupCommandsByCategory(commands: Command[]): Map<string, Command[]> {
|
||||||
|
const grouped = new Map<string, Command[]>();
|
||||||
|
|
||||||
|
for (const command of commands) {
|
||||||
|
const category = command.category || 'Other';
|
||||||
|
if (!grouped.has(category)) {
|
||||||
|
grouped.set(category, []);
|
||||||
|
}
|
||||||
|
grouped.get(category)!.push(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
268
frontend/src/lib/components/canvas/AlignmentToolbar.svelte
Normal file
268
frontend/src/lib/components/canvas/AlignmentToolbar.svelte
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Alignment toolbar component
|
||||||
|
* Provides UI buttons for alignment and distribution operations
|
||||||
|
*/
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { selectionCount } from '$lib/stores/selection';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
$: disabled = $selectionCount < 2;
|
||||||
|
$: distributeDisabled = $selectionCount < 3;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="alignment-toolbar">
|
||||||
|
<div class="toolbar-section">
|
||||||
|
<div class="section-label">Align</div>
|
||||||
|
<div class="button-group">
|
||||||
|
<button
|
||||||
|
class="toolbar-button"
|
||||||
|
on:click={() => dispatch('align-left')}
|
||||||
|
{disabled}
|
||||||
|
title="Align Left"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="3" y1="6" x2="3" y2="18" />
|
||||||
|
<rect x="7" y="8" width="10" height="3" />
|
||||||
|
<rect x="7" y="13" width="7" height="3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="toolbar-button"
|
||||||
|
on:click={() => dispatch('align-center-h')}
|
||||||
|
{disabled}
|
||||||
|
title="Center Horizontal"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="6" x2="12" y2="18" />
|
||||||
|
<rect x="7" y="8" width="10" height="3" />
|
||||||
|
<rect x="9" y="13" width="6" height="3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="toolbar-button"
|
||||||
|
on:click={() => dispatch('align-right')}
|
||||||
|
{disabled}
|
||||||
|
title="Align Right"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="21" y1="6" x2="21" y2="18" />
|
||||||
|
<rect x="7" y="8" width="10" height="3" />
|
||||||
|
<rect x="10" y="13" width="7" height="3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="separator" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="toolbar-button"
|
||||||
|
on:click={() => dispatch('align-top')}
|
||||||
|
{disabled}
|
||||||
|
title="Align Top"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="6" y1="3" x2="18" y2="3" />
|
||||||
|
<rect x="8" y="7" width="3" height="10" />
|
||||||
|
<rect x="13" y="7" width="3" height="7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="toolbar-button"
|
||||||
|
on:click={() => dispatch('align-center-v')}
|
||||||
|
{disabled}
|
||||||
|
title="Center Vertical"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="6" y1="12" x2="18" y2="12" />
|
||||||
|
<rect x="8" y="7" width="3" height="10" />
|
||||||
|
<rect x="13" y="9" width="3" height="6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="toolbar-button"
|
||||||
|
on:click={() => dispatch('align-bottom')}
|
||||||
|
{disabled}
|
||||||
|
title="Align Bottom"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="6" y1="21" x2="18" y2="21" />
|
||||||
|
<rect x="8" y="7" width="3" height="10" />
|
||||||
|
<rect x="13" y="10" width="3" height="7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-section">
|
||||||
|
<div class="section-label">Distribute</div>
|
||||||
|
<div class="button-group">
|
||||||
|
<button
|
||||||
|
class="toolbar-button"
|
||||||
|
on:click={() => dispatch('distribute-h')}
|
||||||
|
disabled={distributeDisabled}
|
||||||
|
title="Distribute Horizontal"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<rect x="5" y="8" width="3" height="8" />
|
||||||
|
<rect x="11" y="8" width="3" height="8" />
|
||||||
|
<rect x="17" y="8" width="3" height="8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="toolbar-button"
|
||||||
|
on:click={() => dispatch('distribute-v')}
|
||||||
|
disabled={distributeDisabled}
|
||||||
|
title="Distribute Vertical"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<rect x="8" y="5" width="8" height="3" />
|
||||||
|
<rect x="8" y="11" width="8" height="3" />
|
||||||
|
<rect x="8" y="17" width="8" height="3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.alignment-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--color-bg, #ffffff);
|
||||||
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-button {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
background-color: var(--color-bg-secondary, #f9fafb);
|
||||||
|
border: 1px solid var(--color-border, #d1d5db);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-button:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-bg-hover, #f3f4f6);
|
||||||
|
border-color: var(--color-primary, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-button:active:not(:disabled) {
|
||||||
|
background-color: var(--color-bg-active, #e5e7eb);
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background-color: var(--color-border, #d1d5db);
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
245
frontend/src/lib/components/canvas/ColorPicker.svelte
Normal file
245
frontend/src/lib/components/canvas/ColorPicker.svelte
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Color picker component for groups
|
||||||
|
* Allows selecting colors for group labels
|
||||||
|
*/
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { getGroupColorSuggestions } from '$lib/canvas/operations/group';
|
||||||
|
|
||||||
|
export let selectedColor: string = '#3B82F6';
|
||||||
|
export let show: boolean = false;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
const colorSuggestions = getGroupColorSuggestions();
|
||||||
|
let customColor = selectedColor;
|
||||||
|
|
||||||
|
function handleColorSelect(color: string) {
|
||||||
|
selectedColor = color;
|
||||||
|
customColor = color;
|
||||||
|
dispatch('select', { color });
|
||||||
|
show = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCustomColorChange(event: Event) {
|
||||||
|
const color = (event.target as HTMLInputElement).value;
|
||||||
|
customColor = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCustomColorSelect() {
|
||||||
|
selectedColor = customColor;
|
||||||
|
dispatch('select', { color: customColor });
|
||||||
|
show = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
show = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if show}
|
||||||
|
<div
|
||||||
|
class="color-picker-backdrop"
|
||||||
|
on:click={handleBackdropClick}
|
||||||
|
on:keydown={(e) => e.key === 'Escape' && handleClose()}
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="color-picker" role="dialog">
|
||||||
|
<div class="picker-header">
|
||||||
|
<h4>Choose Color</h4>
|
||||||
|
<button class="close-button" on:click={handleClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="color-presets">
|
||||||
|
{#each colorSuggestions as color}
|
||||||
|
<button
|
||||||
|
class="color-swatch"
|
||||||
|
class:selected={selectedColor === color}
|
||||||
|
style="background-color: {color}"
|
||||||
|
on:click={() => handleColorSelect(color)}
|
||||||
|
title={color}
|
||||||
|
>
|
||||||
|
{#if selectedColor === color}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="3"
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="custom-color">
|
||||||
|
<label for="custom-color-input">Custom Color</label>
|
||||||
|
<div class="custom-color-input">
|
||||||
|
<input
|
||||||
|
id="custom-color-input"
|
||||||
|
type="color"
|
||||||
|
bind:value={customColor}
|
||||||
|
on:change={handleCustomColorChange}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customColor}
|
||||||
|
on:input={handleCustomColorChange}
|
||||||
|
placeholder="#RRGGBB"
|
||||||
|
maxlength="7"
|
||||||
|
/>
|
||||||
|
<button class="button-small" on:click={handleCustomColorSelect}>Apply</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.color-picker-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker {
|
||||||
|
background-color: var(--color-bg, #ffffff);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||||
|
max-width: 320px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
background-color: var(--color-bg-hover, #f3f4f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-presets {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
border-color: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch.selected {
|
||||||
|
border-color: var(--color-text, #111827);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-bg, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-color {
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-color label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-color-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='color'] {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid var(--color-border, #d1d5db);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'] {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--color-border, #d1d5db);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-small {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background-color: var(--color-primary, #3b82f6);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-small:hover {
|
||||||
|
background-color: var(--color-primary-hover, #2563eb);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
166
frontend/src/lib/components/canvas/GridSettings.svelte
Normal file
166
frontend/src/lib/components/canvas/GridSettings.svelte
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Grid settings UI component
|
||||||
|
* Configures grid size, visibility, and snap-to-grid
|
||||||
|
*/
|
||||||
|
import { grid } from '$lib/canvas/grid';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
function handleSizeChange(event: Event) {
|
||||||
|
const value = parseInt((event.target as HTMLInputElement).value, 10);
|
||||||
|
grid.setSize(value);
|
||||||
|
dispatch('settings-change', { size: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibleToggle() {
|
||||||
|
grid.toggleVisible();
|
||||||
|
dispatch('settings-change', { visible: !$grid.visible });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSnapToggle() {
|
||||||
|
grid.toggleSnap();
|
||||||
|
dispatch('settings-change', { snap: !$grid.snapEnabled });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid-settings">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h4>Grid Settings</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-content">
|
||||||
|
<!-- Grid Visibility -->
|
||||||
|
<div class="setting-row">
|
||||||
|
<label for="grid-visible">
|
||||||
|
<input
|
||||||
|
id="grid-visible"
|
||||||
|
type="checkbox"
|
||||||
|
checked={$grid.visible}
|
||||||
|
on:change={handleVisibleToggle}
|
||||||
|
/>
|
||||||
|
<span>Show Grid</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Snap to Grid -->
|
||||||
|
<div class="setting-row">
|
||||||
|
<label for="grid-snap">
|
||||||
|
<input
|
||||||
|
id="grid-snap"
|
||||||
|
type="checkbox"
|
||||||
|
checked={$grid.snapEnabled}
|
||||||
|
on:change={handleSnapToggle}
|
||||||
|
/>
|
||||||
|
<span>Snap to Grid</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid Size -->
|
||||||
|
<div class="setting-row">
|
||||||
|
<label for="grid-size">
|
||||||
|
Grid Size
|
||||||
|
<span class="value">{$grid.size}px</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="grid-size"
|
||||||
|
type="range"
|
||||||
|
min="5"
|
||||||
|
max="200"
|
||||||
|
step="5"
|
||||||
|
value={$grid.size}
|
||||||
|
on:input={handleSizeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-settings {
|
||||||
|
background-color: var(--color-bg, #ffffff);
|
||||||
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
label span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range'] {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--color-bg-secondary, #e5e7eb);
|
||||||
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-primary, #3b82f6);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-primary, #3b82f6);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
266
frontend/src/lib/components/canvas/GroupAnnotation.svelte
Normal file
266
frontend/src/lib/components/canvas/GroupAnnotation.svelte
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Group annotation UI component
|
||||||
|
* Displays and edits group name, color, and annotation
|
||||||
|
*/
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import type { Group } from '$lib/api/groups';
|
||||||
|
|
||||||
|
export let group: Group;
|
||||||
|
export let editing: boolean = false;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
let editName = group.name;
|
||||||
|
let editAnnotation = group.annotation || '';
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
dispatch('save', {
|
||||||
|
name: editName,
|
||||||
|
annotation: editAnnotation || null,
|
||||||
|
});
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
editName = group.name;
|
||||||
|
editAnnotation = group.annotation || '';
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit() {
|
||||||
|
editing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
dispatch('delete');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColorChange() {
|
||||||
|
dispatch('color-change');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="group-annotation" style="border-left: 4px solid {group.color}">
|
||||||
|
<div class="annotation-header">
|
||||||
|
<button
|
||||||
|
class="color-indicator"
|
||||||
|
style="background-color: {group.color}"
|
||||||
|
on:click={handleColorChange}
|
||||||
|
title="Change color"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if editing}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={editName}
|
||||||
|
class="name-input"
|
||||||
|
placeholder="Group name"
|
||||||
|
maxlength="255"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<h4 class="group-name" on:dblclick={handleEdit}>{group.name}</h4>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="header-actions">
|
||||||
|
<span class="member-count" title="{group.member_count} images">
|
||||||
|
{group.member_count}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if !editing}
|
||||||
|
<button class="icon-button" on:click={handleEdit} title="Edit group"> ✎ </button>
|
||||||
|
<button class="icon-button delete" on:click={handleDelete} title="Delete group"> × </button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="annotation-body">
|
||||||
|
{#if editing}
|
||||||
|
<textarea
|
||||||
|
bind:value={editAnnotation}
|
||||||
|
class="annotation-input"
|
||||||
|
placeholder="Add annotation..."
|
||||||
|
maxlength="10000"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<button class="button button-secondary" on:click={handleCancel}>Cancel</button>
|
||||||
|
<button class="button button-primary" on:click={handleSave}>Save</button>
|
||||||
|
</div>
|
||||||
|
{:else if group.annotation}
|
||||||
|
<p class="annotation-text" on:dblclick={handleEdit}>{group.annotation}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="annotation-empty" on:dblclick={handleEdit}>No annotation</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.group-annotation {
|
||||||
|
background-color: var(--color-bg, #ffffff);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-indicator {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-indicator:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #111827);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name:hover {
|
||||||
|
color: var(--color-primary, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: 1px solid var(--color-border, #d1d5db);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
background-color: var(--color-bg-secondary, #f3f4f6);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover {
|
||||||
|
background-color: var(--color-bg-hover, #f3f4f6);
|
||||||
|
color: var(--color-text, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button.delete:hover {
|
||||||
|
background-color: var(--color-error-bg, #fee2e2);
|
||||||
|
color: var(--color-error, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-body {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-text:hover {
|
||||||
|
color: var(--color-text, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted, #9ca3af);
|
||||||
|
font-style: italic;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--color-border, #d1d5db);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
background-color: var(--color-bg-secondary, #f3f4f6);
|
||||||
|
color: var(--color-text, #374151);
|
||||||
|
border-color: var(--color-border, #d1d5db);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary:hover {
|
||||||
|
background-color: var(--color-bg-hover, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary {
|
||||||
|
background-color: var(--color-primary, #3b82f6);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary:hover {
|
||||||
|
background-color: var(--color-primary-hover, #2563eb);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
212
frontend/src/lib/components/commands/Palette.svelte
Normal file
212
frontend/src/lib/components/commands/Palette.svelte
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { commandRegistry, type Command } from '$lib/commands/registry';
|
||||||
|
import { searchCommands } from '$lib/commands/search';
|
||||||
|
|
||||||
|
export let isOpen: boolean = false;
|
||||||
|
export let onClose: () => void;
|
||||||
|
|
||||||
|
let searchQuery = '';
|
||||||
|
let allCommands: Command[] = [];
|
||||||
|
let filteredCommands: Command[] = [];
|
||||||
|
let selectedIndex = 0;
|
||||||
|
let searchInput: HTMLInputElement | null = null;
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (searchQuery) {
|
||||||
|
filteredCommands = searchCommands(allCommands, searchQuery);
|
||||||
|
} else {
|
||||||
|
// Show recently used first when no query
|
||||||
|
const recent = commandRegistry.getRecentlyUsed();
|
||||||
|
const otherCommands = allCommands.filter((cmd) => !recent.find((r) => r.id === cmd.id));
|
||||||
|
filteredCommands = [...recent, ...otherCommands];
|
||||||
|
}
|
||||||
|
selectedIndex = 0; // Reset selection when results change
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
allCommands = commandRegistry.getAllCommands();
|
||||||
|
commandRegistry.loadRecentlyUsed();
|
||||||
|
filteredCommands = commandRegistry.getRecentlyUsed();
|
||||||
|
|
||||||
|
// Focus search input when opened
|
||||||
|
if (isOpen && searchInput) {
|
||||||
|
searchInput.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
selectedIndex = Math.min(selectedIndex + 1, filteredCommands.length - 1);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
selectedIndex = Math.max(selectedIndex - 1, 0);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
event.preventDefault();
|
||||||
|
executeSelected();
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeSelected() {
|
||||||
|
const command = filteredCommands[selectedIndex];
|
||||||
|
if (command) {
|
||||||
|
try {
|
||||||
|
await commandRegistry.execute(command.id);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Command execution failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCommandClick(command: Command) {
|
||||||
|
commandRegistry.execute(command.id);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOverlayClick(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div
|
||||||
|
class="palette-overlay"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
on:click={handleOverlayClick}
|
||||||
|
on:keydown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div class="palette" role="dialog" aria-modal="true">
|
||||||
|
<input
|
||||||
|
bind:this={searchInput}
|
||||||
|
type="text"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Type a command or search..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
on:keydown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="commands-list">
|
||||||
|
{#if filteredCommands.length === 0}
|
||||||
|
<div class="no-results">No commands found</div>
|
||||||
|
{:else}
|
||||||
|
{#each filteredCommands as command, index}
|
||||||
|
<button
|
||||||
|
class="command-item"
|
||||||
|
class:selected={index === selectedIndex}
|
||||||
|
on:click={() => handleCommandClick(command)}
|
||||||
|
>
|
||||||
|
<div class="command-info">
|
||||||
|
<span class="command-name">{command.name}</span>
|
||||||
|
<span class="command-description">{command.description}</span>
|
||||||
|
</div>
|
||||||
|
{#if command.shortcut}
|
||||||
|
<span class="command-shortcut">{command.shortcut}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.palette-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 15vh;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commands-list {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: none;
|
||||||
|
background: white;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item:hover,
|
||||||
|
.command-item.selected {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-description {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-shortcut {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
339
frontend/src/lib/components/export/ExportModal.svelte
Normal file
339
frontend/src/lib/components/export/ExportModal.svelte
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
exportBoardZip,
|
||||||
|
exportBoardComposite,
|
||||||
|
getExportInfo,
|
||||||
|
type ExportInfo,
|
||||||
|
} from '$lib/api/export';
|
||||||
|
|
||||||
|
export let boardId: string;
|
||||||
|
export let onClose: () => void;
|
||||||
|
|
||||||
|
let exportInfo: ExportInfo | null = null;
|
||||||
|
let loading = false;
|
||||||
|
let error = '';
|
||||||
|
let exportType: 'zip' | 'composite' = 'zip';
|
||||||
|
let compositeScale: number = 1.0;
|
||||||
|
let compositeFormat: 'PNG' | 'JPEG' = 'PNG';
|
||||||
|
let progress = 0;
|
||||||
|
let exporting = false;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadExportInfo();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadExportInfo() {
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
exportInfo = await getExportInfo(boardId);
|
||||||
|
} catch (err: any) {
|
||||||
|
error = `Failed to load export info: ${err.message || err}`;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
try {
|
||||||
|
exporting = true;
|
||||||
|
progress = 0;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
// Simulate progress (since we don't have real progress tracking yet)
|
||||||
|
const progressInterval = setInterval(() => {
|
||||||
|
if (progress < 90) {
|
||||||
|
progress += 10;
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
if (exportType === 'zip') {
|
||||||
|
await exportBoardZip(boardId);
|
||||||
|
} else {
|
||||||
|
await exportBoardComposite(boardId, compositeScale, compositeFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
progress = 100;
|
||||||
|
|
||||||
|
// Close modal after short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 500);
|
||||||
|
} catch (err: any) {
|
||||||
|
error = `Export failed: ${err.message || err}`;
|
||||||
|
} finally {
|
||||||
|
exporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOverlayClick(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="modal-overlay"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
on:click={handleOverlayClick}
|
||||||
|
on:keydown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div class="modal" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Export Board</h2>
|
||||||
|
<button class="close-btn" on:click={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
{#if error}
|
||||||
|
<div class="error-message">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p>Loading export information...</p>
|
||||||
|
{:else if exportInfo}
|
||||||
|
<div class="export-info">
|
||||||
|
<p><strong>{exportInfo.image_count}</strong> images</p>
|
||||||
|
<p>Total size: <strong>{formatBytes(exportInfo.total_size_bytes)}</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="export-options">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={exportType} value="zip" />
|
||||||
|
<span>ZIP Archive</span>
|
||||||
|
</label>
|
||||||
|
<p class="option-description">
|
||||||
|
Download all images as individual files in a ZIP archive
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={exportType} value="composite" />
|
||||||
|
<span>Composite Image</span>
|
||||||
|
</label>
|
||||||
|
<p class="option-description">Export the entire board layout as a single image</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if exportType === 'composite'}
|
||||||
|
<div class="composite-options">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="scale">Resolution:</label>
|
||||||
|
<select id="scale" bind:value={compositeScale}>
|
||||||
|
<option value={0.5}>0.5x (Half)</option>
|
||||||
|
<option value={1.0}>1x (Original)</option>
|
||||||
|
<option value={2.0}>2x (Double)</option>
|
||||||
|
<option value={4.0}>4x (Quadruple)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="format">Format:</label>
|
||||||
|
<select id="format" bind:value={compositeFormat}>
|
||||||
|
<option value="PNG">PNG (Lossless)</option>
|
||||||
|
<option value="JPEG">JPEG (Smaller file)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if exporting}
|
||||||
|
<div class="progress-section">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: {progress}%"></div>
|
||||||
|
</div>
|
||||||
|
<p class="progress-text">{progress}% Complete</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-cancel" on:click={onClose} disabled={exporting}> Cancel </button>
|
||||||
|
<button class="btn-export" on:click={handleExport} disabled={exporting}>
|
||||||
|
{exporting ? 'Exporting...' : 'Export'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-info {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-info p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-options {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-description {
|
||||||
|
margin: 0.25rem 0 0 1.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-options {
|
||||||
|
margin-left: 1.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-section {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #3b82f6;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel,
|
||||||
|
.btn-export {
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-export {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:disabled,
|
||||||
|
.btn-export:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
187
frontend/src/lib/components/settings/QualitySelector.svelte
Normal file
187
frontend/src/lib/components/settings/QualitySelector.svelte
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { qualityStore, type QualityMode, type QualityLevel } from '$lib/stores/quality';
|
||||||
|
import { runConnectionTest } from '$lib/utils/adaptive-quality';
|
||||||
|
|
||||||
|
let mode: QualityMode = 'auto';
|
||||||
|
let manualLevel: QualityLevel = 'medium';
|
||||||
|
let detectedLevel: QualityLevel = 'medium';
|
||||||
|
let connectionSpeed: number = 0;
|
||||||
|
let testing = false;
|
||||||
|
|
||||||
|
// Subscribe to quality store
|
||||||
|
qualityStore.subscribe((settings) => {
|
||||||
|
mode = settings.mode;
|
||||||
|
manualLevel = settings.manualLevel;
|
||||||
|
detectedLevel = settings.detectedLevel;
|
||||||
|
connectionSpeed = settings.connectionSpeed;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleModeChange(event: Event) {
|
||||||
|
const target = event.target as HTMLSelectElement;
|
||||||
|
const newMode = target.value as QualityMode;
|
||||||
|
qualityStore.setMode(newMode);
|
||||||
|
|
||||||
|
// Run test immediately when switching to auto mode
|
||||||
|
if (newMode === 'auto') {
|
||||||
|
handleTestConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleManualLevelChange(event: Event) {
|
||||||
|
const target = event.target as HTMLSelectElement;
|
||||||
|
const newLevel = target.value as QualityLevel;
|
||||||
|
qualityStore.setManualLevel(newLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTestConnection() {
|
||||||
|
testing = true;
|
||||||
|
try {
|
||||||
|
await runConnectionTest();
|
||||||
|
} finally {
|
||||||
|
testing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpeed(mbps: number): string {
|
||||||
|
if (mbps < 1) {
|
||||||
|
return `${(mbps * 1000).toFixed(0)} Kbps`;
|
||||||
|
}
|
||||||
|
return `${mbps.toFixed(1)} Mbps`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="quality-selector">
|
||||||
|
<h3>Image Quality Settings</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mode">Mode:</label>
|
||||||
|
<select id="mode" value={mode} on:change={handleModeChange}>
|
||||||
|
<option value="auto">Auto (Detect Connection Speed)</option>
|
||||||
|
<option value="manual">Manual</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if mode === 'auto'}
|
||||||
|
<div class="auto-section">
|
||||||
|
<div class="detected-info">
|
||||||
|
<p>
|
||||||
|
<strong>Detected Speed:</strong>
|
||||||
|
{formatSpeed(connectionSpeed)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Quality Level:</strong>
|
||||||
|
<span class="quality-badge {detectedLevel}">{detectedLevel}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn-test" on:click={handleTestConnection} disabled={testing}>
|
||||||
|
{testing ? 'Testing...' : 'Test Now'}
|
||||||
|
</button>
|
||||||
|
<p class="help-text">Connection speed is re-tested every 5 minutes</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="manual-section">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="manual-level">Quality Level:</label>
|
||||||
|
<select id="manual-level" value={manualLevel} on:change={handleManualLevelChange}>
|
||||||
|
<option value="low">Low (Fast loading, lower quality)</option>
|
||||||
|
<option value="medium">Medium (Balanced)</option>
|
||||||
|
<option value="high">High (Best quality, slower)</option>
|
||||||
|
<option value="original">Original (Full resolution)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.quality-selector {
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-section,
|
||||||
|
.manual-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detected-info {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detected-info p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-badge.low {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-badge.medium {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-badge.high {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-test {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-test:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
314
frontend/src/lib/components/sharing/ShareModal.svelte
Normal file
314
frontend/src/lib/components/sharing/ShareModal.svelte
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
createShareLink,
|
||||||
|
listShareLinks,
|
||||||
|
revokeShareLink,
|
||||||
|
getShareUrl,
|
||||||
|
type ShareLink,
|
||||||
|
} from '$lib/api/sharing';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
export let boardId: string;
|
||||||
|
export let onClose: () => void;
|
||||||
|
|
||||||
|
let shareLinks: ShareLink[] = [];
|
||||||
|
let permissionLevel: 'view-only' | 'view-comment' = 'view-only';
|
||||||
|
let loading = false;
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadShareLinks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadShareLinks() {
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
shareLinks = await listShareLinks(boardId);
|
||||||
|
} catch (err) {
|
||||||
|
error = `Failed to load share links: ${err}`;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateLink() {
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
await createShareLink(boardId, { permission_level: permissionLevel });
|
||||||
|
await loadShareLinks();
|
||||||
|
} catch (err) {
|
||||||
|
error = `Failed to create share link: ${err}`;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevokeLink(linkId: string) {
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
await revokeShareLink(boardId, linkId);
|
||||||
|
await loadShareLinks();
|
||||||
|
} catch (err) {
|
||||||
|
error = `Failed to revoke share link: ${err}`;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(token: string) {
|
||||||
|
const url = getShareUrl(token);
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOverlayClick(event: MouseEvent) {
|
||||||
|
// Only close if clicking directly on the overlay, not its children
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="modal-overlay"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
on:click={handleOverlayClick}
|
||||||
|
on:keydown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div class="modal" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Share Board</h2>
|
||||||
|
<button class="close-btn" on:click={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
{#if error}
|
||||||
|
<div class="error-message">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="create-section">
|
||||||
|
<h3>Create New Share Link</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="permission">Permission Level:</label>
|
||||||
|
<select id="permission" bind:value={permissionLevel}>
|
||||||
|
<option value="view-only">View Only</option>
|
||||||
|
<option value="view-comment">View + Comment</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" on:click={handleCreateLink} disabled={loading}>
|
||||||
|
Create Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="links-section">
|
||||||
|
<h3>Existing Share Links</h3>
|
||||||
|
{#if loading}
|
||||||
|
<p>Loading...</p>
|
||||||
|
{:else if shareLinks.length === 0}
|
||||||
|
<p>No share links yet.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="links-list">
|
||||||
|
{#each shareLinks as link}
|
||||||
|
<div class="link-item" class:revoked={link.is_revoked}>
|
||||||
|
<div class="link-info">
|
||||||
|
<span class="permission-badge">{link.permission_level}</span>
|
||||||
|
<span class="access-count">{link.access_count} views</span>
|
||||||
|
{#if link.is_revoked}
|
||||||
|
<span class="revoked-badge">Revoked</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="link-actions">
|
||||||
|
{#if !link.is_revoked}
|
||||||
|
<button class="btn-copy" on:click={() => copyToClipboard(link.token)}>
|
||||||
|
Copy Link
|
||||||
|
</button>
|
||||||
|
<button class="btn-danger" on:click={() => handleRevokeLink(link.id)}>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-section,
|
||||||
|
.links-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-item {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-item.revoked {
|
||||||
|
opacity: 0.6;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-badge,
|
||||||
|
.revoked-badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-badge {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revoked-badge {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-count {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy,
|
||||||
|
.btn-danger {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
158
frontend/src/lib/stores/groups.ts
Normal file
158
frontend/src/lib/stores/groups.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Groups store for managing image groups
|
||||||
|
* Handles group state and operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
import type { Writable } from 'svelte/store';
|
||||||
|
import * as groupsApi from '$lib/api/groups';
|
||||||
|
import type { Group, GroupCreateData, GroupUpdateData } from '$lib/api/groups';
|
||||||
|
|
||||||
|
export interface GroupsState {
|
||||||
|
groups: Group[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATE: GroupsState = {
|
||||||
|
groups: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create groups store
|
||||||
|
*/
|
||||||
|
function createGroupsStore() {
|
||||||
|
const { subscribe, set, update }: Writable<GroupsState> = writable(DEFAULT_STATE);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
set,
|
||||||
|
update,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load groups for a board
|
||||||
|
*/
|
||||||
|
load: async (boardId: string) => {
|
||||||
|
update((state) => ({ ...state, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groups = await groupsApi.listGroups(boardId);
|
||||||
|
set({ groups, loading: false, error: null });
|
||||||
|
} catch (error) {
|
||||||
|
update((state) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to load groups',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new group
|
||||||
|
*/
|
||||||
|
create: async (boardId: string, data: GroupCreateData): Promise<Group | null> => {
|
||||||
|
update((state) => ({ ...state, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const group = await groupsApi.createGroup(boardId, data);
|
||||||
|
update((state) => ({
|
||||||
|
groups: [group, ...state.groups],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
return group;
|
||||||
|
} catch (error) {
|
||||||
|
update((state) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to create group',
|
||||||
|
}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a group
|
||||||
|
*/
|
||||||
|
updateGroup: async (
|
||||||
|
boardId: string,
|
||||||
|
groupId: string,
|
||||||
|
data: GroupUpdateData
|
||||||
|
): Promise<Group | null> => {
|
||||||
|
update((state) => ({ ...state, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const group = await groupsApi.updateGroup(boardId, groupId, data);
|
||||||
|
update((state) => ({
|
||||||
|
groups: state.groups.map((g) => (g.id === groupId ? group : g)),
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
return group;
|
||||||
|
} catch (error) {
|
||||||
|
update((state) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to update group',
|
||||||
|
}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a group
|
||||||
|
*/
|
||||||
|
delete: async (boardId: string, groupId: string): Promise<boolean> => {
|
||||||
|
update((state) => ({ ...state, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await groupsApi.deleteGroup(boardId, groupId);
|
||||||
|
update((state) => ({
|
||||||
|
groups: state.groups.filter((g) => g.id !== groupId),
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
update((state) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete group',
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get group by ID
|
||||||
|
*/
|
||||||
|
getById: (groupId: string): Group | null => {
|
||||||
|
let result: Group | null = null;
|
||||||
|
const unsubscribe = subscribe((state) => {
|
||||||
|
result = state.groups.find((g) => g.id === groupId) || null;
|
||||||
|
});
|
||||||
|
unsubscribe();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all groups
|
||||||
|
*/
|
||||||
|
clear: () => {
|
||||||
|
set(DEFAULT_STATE);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const groups = createGroupsStore();
|
||||||
|
|
||||||
|
// Derived stores
|
||||||
|
export const groupsLoading = derived(groups, ($groups) => $groups.loading);
|
||||||
|
|
||||||
|
export const groupsError = derived(groups, ($groups) => $groups.error);
|
||||||
|
|
||||||
|
export const groupsList = derived(groups, ($groups) => $groups.groups);
|
||||||
|
|
||||||
|
export const groupCount = derived(groups, ($groups) => $groups.groups.length);
|
||||||
@@ -83,7 +83,8 @@ export async function uploadSingleImage(file: File): Promise<Image> {
|
|||||||
return image;
|
return image;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Update progress to error
|
// Update progress to error
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
|
const errorMessage =
|
||||||
|
(error as { error?: string })?.error || (error as Error)?.message || 'Upload failed';
|
||||||
uploadProgress.update((items) =>
|
uploadProgress.update((items) =>
|
||||||
items.map((item) =>
|
items.map((item) =>
|
||||||
item.filename === file.name ? { ...item, status: 'error', error: errorMessage } : item
|
item.filename === file.name ? { ...item, status: 'error', error: errorMessage } : item
|
||||||
|
|||||||
138
frontend/src/lib/stores/quality.ts
Normal file
138
frontend/src/lib/stores/quality.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Quality settings store for adaptive image quality.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
import type { Writable, Readable } from 'svelte/store';
|
||||||
|
|
||||||
|
export type QualityLevel = 'low' | 'medium' | 'high' | 'original';
|
||||||
|
export type QualityMode = 'auto' | 'manual';
|
||||||
|
|
||||||
|
export interface QualitySettings {
|
||||||
|
mode: QualityMode;
|
||||||
|
manualLevel: QualityLevel;
|
||||||
|
detectedLevel: QualityLevel;
|
||||||
|
connectionSpeed: number; // Mbps
|
||||||
|
lastTestTime: number; // timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'webref_quality_settings';
|
||||||
|
const RETEST_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
// Load saved settings from localStorage
|
||||||
|
function loadSettings(): QualitySettings {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return getDefaultSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
return JSON.parse(saved);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load quality settings:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDefaultSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultSettings(): QualitySettings {
|
||||||
|
return {
|
||||||
|
mode: 'auto',
|
||||||
|
manualLevel: 'medium',
|
||||||
|
detectedLevel: 'medium',
|
||||||
|
connectionSpeed: 3.0,
|
||||||
|
lastTestTime: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save settings to localStorage
|
||||||
|
function saveSettings(settings: QualitySettings): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save quality settings:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the store
|
||||||
|
function createQualityStore() {
|
||||||
|
const { subscribe, set, update }: Writable<QualitySettings> = writable(loadSettings());
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set quality mode (auto or manual).
|
||||||
|
*/
|
||||||
|
setMode(mode: QualityMode) {
|
||||||
|
update((settings) => {
|
||||||
|
const updated = { ...settings, mode };
|
||||||
|
saveSettings(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set manual quality level.
|
||||||
|
*/
|
||||||
|
setManualLevel(level: QualityLevel) {
|
||||||
|
update((settings) => {
|
||||||
|
const updated = { ...settings, manualLevel: level };
|
||||||
|
saveSettings(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update detected quality level based on connection test.
|
||||||
|
*/
|
||||||
|
updateDetectedQuality(speed: number, level: QualityLevel) {
|
||||||
|
update((settings) => {
|
||||||
|
const updated = {
|
||||||
|
...settings,
|
||||||
|
detectedLevel: level,
|
||||||
|
connectionSpeed: speed,
|
||||||
|
lastTestTime: Date.now(),
|
||||||
|
};
|
||||||
|
saveSettings(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if connection test should be run.
|
||||||
|
*/
|
||||||
|
shouldRetest(): boolean {
|
||||||
|
const settings = loadSettings();
|
||||||
|
if (settings.mode !== 'auto') return false;
|
||||||
|
|
||||||
|
const timeSinceTest = Date.now() - settings.lastTestTime;
|
||||||
|
return timeSinceTest > RETEST_INTERVAL;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset to default settings.
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
const defaults = getDefaultSettings();
|
||||||
|
set(defaults);
|
||||||
|
saveSettings(defaults);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the store
|
||||||
|
export const qualityStore = createQualityStore();
|
||||||
|
|
||||||
|
// Derived store for active quality level (respects mode)
|
||||||
|
export const activeQuality: Readable<QualityLevel> = derived(qualityStore, ($quality) => {
|
||||||
|
if ($quality.mode === 'manual') {
|
||||||
|
return $quality.manualLevel;
|
||||||
|
} else {
|
||||||
|
return $quality.detectedLevel;
|
||||||
|
}
|
||||||
|
});
|
||||||
80
frontend/src/lib/utils/adaptive-quality.ts
Normal file
80
frontend/src/lib/utils/adaptive-quality.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Adaptive image quality logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { testConnectionSpeed, determineQualityTier } from './connection-test';
|
||||||
|
import { qualityStore } from '$lib/stores/quality';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize adaptive quality system.
|
||||||
|
* Tests connection speed if in auto mode and needed.
|
||||||
|
*/
|
||||||
|
export async function initializeAdaptiveQuality(): Promise<void> {
|
||||||
|
const settings = get(qualityStore);
|
||||||
|
|
||||||
|
if (settings.mode === 'auto' && qualityStore.shouldRetest()) {
|
||||||
|
await runConnectionTest();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up periodic re-testing in auto mode
|
||||||
|
if (settings.mode === 'auto') {
|
||||||
|
schedulePeriodicTest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run connection speed test and update quality settings.
|
||||||
|
*/
|
||||||
|
export async function runConnectionTest(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await testConnectionSpeed();
|
||||||
|
const qualityLevel = determineQualityTier(result.speed_mbps);
|
||||||
|
|
||||||
|
qualityStore.updateDetectedQuality(result.speed_mbps, qualityLevel);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Connection test failed:', error);
|
||||||
|
// Keep current settings on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule periodic connection testing (every 5 minutes in auto mode).
|
||||||
|
*/
|
||||||
|
function schedulePeriodicTest(): void {
|
||||||
|
const RETEST_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const settings = get(qualityStore);
|
||||||
|
if (settings.mode === 'auto') {
|
||||||
|
runConnectionTest();
|
||||||
|
}
|
||||||
|
}, RETEST_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get thumbnail URL for specified quality level.
|
||||||
|
*
|
||||||
|
* @param imageId - Image UUID
|
||||||
|
* @param quality - Quality level
|
||||||
|
* @returns Thumbnail URL
|
||||||
|
*/
|
||||||
|
export function getThumbnailUrl(
|
||||||
|
imageId: string,
|
||||||
|
quality: 'low' | 'medium' | 'high' | 'original' = 'medium'
|
||||||
|
): string {
|
||||||
|
const apiBase = 'http://localhost:8000/api/v1';
|
||||||
|
return `${apiBase}/images/${imageId}/serve?quality=${quality}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get appropriate thumbnail URL based on current quality settings.
|
||||||
|
*
|
||||||
|
* @param imageId - Image UUID
|
||||||
|
* @returns Thumbnail URL for current quality level
|
||||||
|
*/
|
||||||
|
export function getAdaptiveThumbnailUrl(imageId: string): string {
|
||||||
|
const settings = get(qualityStore);
|
||||||
|
const quality = settings.mode === 'auto' ? settings.detectedLevel : settings.manualLevel;
|
||||||
|
return getThumbnailUrl(imageId, quality);
|
||||||
|
}
|
||||||
120
frontend/src/lib/utils/connection-test.ts
Normal file
120
frontend/src/lib/utils/connection-test.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Connection speed testing utilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ConnectionTestResult {
|
||||||
|
speed_mbps: number;
|
||||||
|
latency_ms: number;
|
||||||
|
quality_tier: 'low' | 'medium' | 'high';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection speed by downloading test data.
|
||||||
|
*
|
||||||
|
* @param testSizeBytes - Size of test data to download (default 100KB)
|
||||||
|
* @returns Connection test results
|
||||||
|
*/
|
||||||
|
export async function testConnectionSpeed(
|
||||||
|
testSizeBytes: number = 100000
|
||||||
|
): Promise<ConnectionTestResult> {
|
||||||
|
try {
|
||||||
|
// Use Network Information API if available
|
||||||
|
interface NavigatorWithConnection extends Navigator {
|
||||||
|
connection?: {
|
||||||
|
effectiveType?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const connection = (navigator as NavigatorWithConnection).connection;
|
||||||
|
if (connection && connection.effectiveType) {
|
||||||
|
const effectiveType = connection.effectiveType;
|
||||||
|
return estimateFromEffectiveType(effectiveType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to download speed test
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/connection/test-data?size=${testSizeBytes}`, {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-cache',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Connection test failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the data
|
||||||
|
const data = await response.arrayBuffer();
|
||||||
|
const endTime = performance.now();
|
||||||
|
|
||||||
|
// Calculate speed
|
||||||
|
const durationSeconds = (endTime - startTime) / 1000;
|
||||||
|
const dataSizeBits = data.byteLength * 8;
|
||||||
|
const speedMbps = dataSizeBits / durationSeconds / 1_000_000;
|
||||||
|
const latencyMs = endTime - startTime;
|
||||||
|
|
||||||
|
// Determine quality tier
|
||||||
|
const qualityTier = determineQualityTier(speedMbps);
|
||||||
|
|
||||||
|
return {
|
||||||
|
speed_mbps: speedMbps,
|
||||||
|
latency_ms: latencyMs,
|
||||||
|
quality_tier: qualityTier,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Connection test failed:', error);
|
||||||
|
// Return medium quality as fallback
|
||||||
|
return {
|
||||||
|
speed_mbps: 3.0,
|
||||||
|
latency_ms: 100,
|
||||||
|
quality_tier: 'medium',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate connection speed from Network Information API effective type.
|
||||||
|
*
|
||||||
|
* @param effectiveType - Effective connection type from Network Information API
|
||||||
|
* @returns Estimated connection test result
|
||||||
|
*/
|
||||||
|
function estimateFromEffectiveType(effectiveType: string): ConnectionTestResult {
|
||||||
|
const estimates: Record<string, ConnectionTestResult> = {
|
||||||
|
'slow-2g': { speed_mbps: 0.05, latency_ms: 2000, quality_tier: 'low' },
|
||||||
|
'2g': { speed_mbps: 0.25, latency_ms: 1400, quality_tier: 'low' },
|
||||||
|
'3g': { speed_mbps: 0.7, latency_ms: 270, quality_tier: 'low' },
|
||||||
|
'4g': { speed_mbps: 10.0, latency_ms: 50, quality_tier: 'high' },
|
||||||
|
};
|
||||||
|
|
||||||
|
return estimates[effectiveType] || estimates['4g'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine quality tier based on connection speed.
|
||||||
|
*
|
||||||
|
* @param speedMbps - Connection speed in Mbps
|
||||||
|
* @returns Quality tier
|
||||||
|
*/
|
||||||
|
export function determineQualityTier(speedMbps: number): 'low' | 'medium' | 'high' {
|
||||||
|
if (speedMbps < 1.0) {
|
||||||
|
return 'low';
|
||||||
|
} else if (speedMbps < 5.0) {
|
||||||
|
return 'medium';
|
||||||
|
} else {
|
||||||
|
return 'high';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Network Information API is available.
|
||||||
|
*
|
||||||
|
* @returns True if available
|
||||||
|
*/
|
||||||
|
export function isNetworkInformationAvailable(): boolean {
|
||||||
|
interface NavigatorWithConnection extends Navigator {
|
||||||
|
connection?: {
|
||||||
|
effectiveType?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const nav = navigator as NavigatorWithConnection;
|
||||||
|
return 'connection' in nav && !!nav.connection && 'effectiveType' in nav.connection;
|
||||||
|
}
|
||||||
57
frontend/src/routes/+page.svelte
Normal file
57
frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Client-side redirect based on auth token
|
||||||
|
if (browser) {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (token) {
|
||||||
|
goto('/boards');
|
||||||
|
} else {
|
||||||
|
goto('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Reference Board Viewer</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="loading-container">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid #e5e7eb;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
2
frontend/src/routes/+page.ts
Normal file
2
frontend/src/routes/+page.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Disable server-side rendering for the root page
|
||||||
|
export const ssr = false;
|
||||||
684
frontend/src/routes/boards/[id]/+page.svelte
Normal file
684
frontend/src/routes/boards/[id]/+page.svelte
Normal file
@@ -0,0 +1,684 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { boards, currentBoard } from '$lib/stores/boards';
|
||||||
|
import {
|
||||||
|
boardImages,
|
||||||
|
loadBoardImages,
|
||||||
|
uploadSingleImage,
|
||||||
|
uploadZipFile,
|
||||||
|
addImageToBoard,
|
||||||
|
} from '$lib/stores/images';
|
||||||
|
import { viewport } from '$lib/stores/viewport';
|
||||||
|
import Stage from '$lib/canvas/Stage.svelte';
|
||||||
|
import CanvasImage from '$lib/canvas/Image.svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import * as imagesApi from '$lib/api/images';
|
||||||
|
|
||||||
|
let loading = true;
|
||||||
|
let error = '';
|
||||||
|
let uploadError = '';
|
||||||
|
let uploadSuccess = '';
|
||||||
|
let uploading = false;
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
|
let canvasContainer: HTMLDivElement;
|
||||||
|
let canvasWidth = 0;
|
||||||
|
let canvasHeight = 0;
|
||||||
|
let stageComponent: Stage;
|
||||||
|
let stageReady = false;
|
||||||
|
let loadedImagesCount = 0;
|
||||||
|
|
||||||
|
$: boardId = $page.params.id;
|
||||||
|
$: canvasLayer = stageReady && stageComponent ? stageComponent.getLayer() : null;
|
||||||
|
|
||||||
|
// Track loaded images and force redraw
|
||||||
|
$: if (loadedImagesCount > 0 && stageComponent) {
|
||||||
|
const layer = stageComponent.getLayer();
|
||||||
|
if (layer) {
|
||||||
|
setTimeout(() => layer.batchDraw(), 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
await boards.loadBoard(boardId);
|
||||||
|
await loadBoardImages(boardId);
|
||||||
|
|
||||||
|
// Load viewport state from board if available
|
||||||
|
if ($currentBoard?.viewport_state) {
|
||||||
|
viewport.loadState($currentBoard.viewport_state);
|
||||||
|
} else {
|
||||||
|
// Reset to default if no saved state
|
||||||
|
viewport.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set canvas dimensions BEFORE creating stage
|
||||||
|
updateCanvasDimensions();
|
||||||
|
|
||||||
|
// Wait for dimensions to be set
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
// Double-check dimensions are valid
|
||||||
|
if (canvasWidth === 0 || canvasHeight === 0) {
|
||||||
|
console.warn('Canvas dimensions are 0, forcing update...');
|
||||||
|
updateCanvasDimensions();
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', updateCanvasDimensions);
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error = (err as { error?: string })?.error || 'Failed to load board';
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', updateCanvasDimensions);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateCanvasDimensions() {
|
||||||
|
if (canvasContainer) {
|
||||||
|
canvasWidth = canvasContainer.clientWidth;
|
||||||
|
canvasHeight = canvasContainer.clientHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStageReady() {
|
||||||
|
// Wait for next tick to ensure layer is fully ready
|
||||||
|
await tick();
|
||||||
|
stageReady = true;
|
||||||
|
loadedImagesCount = 0; // Reset counter
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageLoaded(_imageId: string) {
|
||||||
|
loadedImagesCount++;
|
||||||
|
|
||||||
|
// Force immediate redraw on each image load
|
||||||
|
if (stageComponent) {
|
||||||
|
const layer = stageComponent.getLayer();
|
||||||
|
const stage = stageComponent.getStage();
|
||||||
|
if (layer && stage) {
|
||||||
|
layer.batchDraw();
|
||||||
|
stage.batchDraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When all images loaded, auto-fit to view on first load
|
||||||
|
if (loadedImagesCount === $boardImages.length) {
|
||||||
|
const layer = stageComponent?.getLayer();
|
||||||
|
const stage = stageComponent?.getStage();
|
||||||
|
|
||||||
|
if (layer && stage) {
|
||||||
|
// Multiple redraws to ensure visibility
|
||||||
|
setTimeout(() => {
|
||||||
|
layer.batchDraw();
|
||||||
|
stage.batchDraw();
|
||||||
|
}, 0);
|
||||||
|
setTimeout(() => {
|
||||||
|
layer.batchDraw();
|
||||||
|
stage.batchDraw();
|
||||||
|
}, 100);
|
||||||
|
setTimeout(() => {
|
||||||
|
layer.batchDraw();
|
||||||
|
stage.batchDraw();
|
||||||
|
|
||||||
|
// Auto-fit images on first load (if they're off-screen)
|
||||||
|
const hasOffscreenImages = $boardImages.some(
|
||||||
|
(bi) =>
|
||||||
|
bi.position.x < -canvasWidth ||
|
||||||
|
bi.position.y < -canvasHeight ||
|
||||||
|
bi.position.x > canvasWidth * 2 ||
|
||||||
|
bi.position.y > canvasHeight * 2
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasOffscreenImages) {
|
||||||
|
fitAllImages();
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImageDragEnd(imageId: string, x: number, y: number) {
|
||||||
|
// Update position on backend
|
||||||
|
try {
|
||||||
|
const boardImage = $boardImages.find((bi) => bi.id === imageId);
|
||||||
|
if (!boardImage) return;
|
||||||
|
|
||||||
|
await imagesApi.updateBoardImage(boardId, boardImage.image_id, {
|
||||||
|
position: { x, y },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update local store
|
||||||
|
boardImages.update((images) =>
|
||||||
|
images.map((img) => (img.id === imageId ? { ...img, position: { x, y } } : img))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update image position:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageSelectionChange(_imageId: string, _isSelected: boolean) {
|
||||||
|
// Selection handling
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileSelect(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
if (!target.files || target.files.length === 0) return;
|
||||||
|
|
||||||
|
await processFiles(Array.from(target.files));
|
||||||
|
|
||||||
|
// Reset input
|
||||||
|
if (target) target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (!event.dataTransfer?.files) return;
|
||||||
|
|
||||||
|
await processFiles(Array.from(event.dataTransfer.files));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processFiles(files: File[]) {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
uploading = true;
|
||||||
|
uploadError = '';
|
||||||
|
uploadSuccess = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let totalUploaded = 0;
|
||||||
|
|
||||||
|
// Calculate starting position (centered on screen with some spacing)
|
||||||
|
let currentX = canvasWidth / 2 - 200;
|
||||||
|
let currentY = canvasHeight / 2 - 200;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// Upload to library first
|
||||||
|
if (file.name.toLowerCase().endsWith('.zip')) {
|
||||||
|
const images = await uploadZipFile(file);
|
||||||
|
// Add each image to board with spaced positions
|
||||||
|
for (const img of images) {
|
||||||
|
await addImageToBoard(boardId, img.id, { x: currentX, y: currentY }, totalUploaded);
|
||||||
|
// Offset next image
|
||||||
|
currentX += 50;
|
||||||
|
currentY += 50;
|
||||||
|
}
|
||||||
|
totalUploaded += images.length;
|
||||||
|
} else if (file.type.startsWith('image/')) {
|
||||||
|
const image = await uploadSingleImage(file);
|
||||||
|
// Add to board at calculated position
|
||||||
|
await addImageToBoard(boardId, image.id, { x: currentX, y: currentY }, totalUploaded);
|
||||||
|
// Offset next image
|
||||||
|
currentX += 50;
|
||||||
|
currentY += 50;
|
||||||
|
totalUploaded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload board images to show new uploads
|
||||||
|
await loadBoardImages(boardId);
|
||||||
|
|
||||||
|
uploadSuccess = `Successfully uploaded ${totalUploaded} image(s)`;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
uploadSuccess = '';
|
||||||
|
}, 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Upload error:', err);
|
||||||
|
uploadError = err.error || err.message || err.detail || 'Upload failed';
|
||||||
|
} finally {
|
||||||
|
uploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFilePicker() {
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditBoard() {
|
||||||
|
goto(`/boards/${boardId}/edit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackToBoards() {
|
||||||
|
goto('/boards');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitAllImages() {
|
||||||
|
if ($boardImages.length === 0) return;
|
||||||
|
|
||||||
|
// Calculate bounding box of all images
|
||||||
|
let minX = Infinity,
|
||||||
|
minY = Infinity,
|
||||||
|
maxX = -Infinity,
|
||||||
|
maxY = -Infinity;
|
||||||
|
|
||||||
|
$boardImages.forEach((bi) => {
|
||||||
|
const imgMinX = bi.position.x;
|
||||||
|
const imgMinY = bi.position.y;
|
||||||
|
const imgMaxX = bi.position.x + (bi.image?.width || 0);
|
||||||
|
const imgMaxY = bi.position.y + (bi.image?.height || 0);
|
||||||
|
|
||||||
|
minX = Math.min(minX, imgMinX);
|
||||||
|
minY = Math.min(minY, imgMinY);
|
||||||
|
maxX = Math.max(maxX, imgMaxX);
|
||||||
|
maxY = Math.max(maxY, imgMaxY);
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentWidth = maxX - minX;
|
||||||
|
const contentHeight = maxY - minY;
|
||||||
|
|
||||||
|
// Calculate zoom to fit
|
||||||
|
const padding = 100;
|
||||||
|
const scaleX = (canvasWidth - padding * 2) / contentWidth;
|
||||||
|
const scaleY = (canvasHeight - padding * 2) / contentHeight;
|
||||||
|
const newZoom = Math.min(scaleX, scaleY, 1.0); // Don't zoom in more than 100%
|
||||||
|
|
||||||
|
// Calculate center position
|
||||||
|
const centerX = (canvasWidth - contentWidth * newZoom) / 2 - minX * newZoom;
|
||||||
|
const centerY = (canvasHeight - contentHeight * newZoom) / 2 - minY * newZoom;
|
||||||
|
|
||||||
|
viewport.set({
|
||||||
|
x: centerX,
|
||||||
|
y: centerY,
|
||||||
|
zoom: newZoom,
|
||||||
|
rotation: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$currentBoard?.title || 'Board'} - Reference Board Viewer</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="board-canvas-page"
|
||||||
|
on:drop={handleDrop}
|
||||||
|
on:dragover={handleDragOver}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Loading board...</p>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="error-message">
|
||||||
|
<p>{error}</p>
|
||||||
|
<button on:click={handleBackToBoards}>Back to Boards</button>
|
||||||
|
</div>
|
||||||
|
{:else if $currentBoard}
|
||||||
|
<!-- Top Toolbar -->
|
||||||
|
<div class="toolbar-top">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<button class="btn-icon" on:click={handleBackToBoards} title="Back to boards">
|
||||||
|
← Boards
|
||||||
|
</button>
|
||||||
|
<div class="title-group">
|
||||||
|
<h1 class="board-title">{$currentBoard.title}</h1>
|
||||||
|
{#if $currentBoard.description}
|
||||||
|
<span class="board-desc">{$currentBoard.description}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<button
|
||||||
|
class="btn-icon"
|
||||||
|
on:click={fitAllImages}
|
||||||
|
title="Fit all images to view"
|
||||||
|
disabled={$boardImages.length === 0}
|
||||||
|
>
|
||||||
|
🔍 Fit All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-upload"
|
||||||
|
on:click={openFilePicker}
|
||||||
|
disabled={uploading}
|
||||||
|
title="Upload images"
|
||||||
|
>
|
||||||
|
{#if uploading}
|
||||||
|
<span class="spinner-small"></span>
|
||||||
|
Uploading...
|
||||||
|
{:else}
|
||||||
|
📤 Upload Images
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary" on:click={handleEditBoard} title="Edit board settings">
|
||||||
|
⚙️ Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden file input -->
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*,.zip"
|
||||||
|
multiple
|
||||||
|
on:change={handleFileSelect}
|
||||||
|
style="display: none;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if uploadSuccess}
|
||||||
|
<div class="success-banner">✓ {uploadSuccess}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if uploadError}
|
||||||
|
<div class="error-banner">⚠ {uploadError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Canvas Area -->
|
||||||
|
<div class="canvas-container" bind:this={canvasContainer}>
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Loading canvas...</p>
|
||||||
|
</div>
|
||||||
|
{:else if $boardImages.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">🖼️</div>
|
||||||
|
<h2>No images yet</h2>
|
||||||
|
<p>Click "Upload Images" or drag & drop files anywhere to get started</p>
|
||||||
|
<button class="btn-upload-large" on:click={openFilePicker}>
|
||||||
|
Upload Your First Image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if canvasWidth > 0 && canvasHeight > 0}
|
||||||
|
<Stage
|
||||||
|
bind:this={stageComponent}
|
||||||
|
width={canvasWidth}
|
||||||
|
height={canvasHeight}
|
||||||
|
{boardId}
|
||||||
|
on:ready={handleStageReady}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if stageReady && canvasLayer}
|
||||||
|
{#each $boardImages as boardImage (boardImage.id)}
|
||||||
|
{#if boardImage.image}
|
||||||
|
<CanvasImage
|
||||||
|
id={boardImage.id}
|
||||||
|
imageId={boardImage.image.id}
|
||||||
|
imageUrl="http://localhost:8000/api/v1/images/{boardImage.image
|
||||||
|
.id}/serve?quality=medium"
|
||||||
|
x={boardImage.position.x}
|
||||||
|
y={boardImage.position.y}
|
||||||
|
width={boardImage.image.width}
|
||||||
|
height={boardImage.image.height}
|
||||||
|
rotation={boardImage.transformations.rotation || 0}
|
||||||
|
scaleX={boardImage.transformations.scale || 1.0}
|
||||||
|
scaleY={boardImage.transformations.scale || 1.0}
|
||||||
|
opacity={boardImage.transformations.opacity || 1.0}
|
||||||
|
zOrder={boardImage.z_order}
|
||||||
|
layer={canvasLayer}
|
||||||
|
onDragEnd={handleImageDragEnd}
|
||||||
|
onSelectionChange={handleImageSelectionChange}
|
||||||
|
onImageLoaded={handleImageLoaded}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<div class="status-bar">
|
||||||
|
<span>Board: {$currentBoard.title}</span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>Images: {$boardImages.length}</span>
|
||||||
|
<span>|</span>
|
||||||
|
<span class="status-ready">● Ready</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="error-message">
|
||||||
|
<p>Board not found</p>
|
||||||
|
<button on:click={handleBackToBoards}>Back to Boards</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.board-canvas-page {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid #e5e7eb;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.toolbar-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-desc {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon,
|
||||||
|
.btn-upload,
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload:hover:not(:disabled) {
|
||||||
|
background: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload-large {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload-large:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #6b7280;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-banner,
|
||||||
|
.error-banner {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-banner {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
border-bottom: 1px solid #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
border-bottom: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Canvas Container */
|
||||||
|
.canvas-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
background: white;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Bar */
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
background: white;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ready {
|
||||||
|
color: #10b981;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
284
frontend/src/routes/library/+page.svelte
Normal file
284
frontend/src/routes/library/+page.svelte
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
listLibraryImages,
|
||||||
|
deleteLibraryImage,
|
||||||
|
getLibraryStats,
|
||||||
|
type LibraryImage,
|
||||||
|
type LibraryStats,
|
||||||
|
} from '$lib/api/library';
|
||||||
|
|
||||||
|
let images: LibraryImage[] = [];
|
||||||
|
let stats: LibraryStats | null = null;
|
||||||
|
let loading = true;
|
||||||
|
let error = '';
|
||||||
|
let searchQuery = '';
|
||||||
|
let _showAddToBoard = false;
|
||||||
|
let _selectedImage: LibraryImage | null = null;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadLibrary();
|
||||||
|
await loadStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadLibrary() {
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
const result = await listLibraryImages(searchQuery || undefined);
|
||||||
|
images = result.images;
|
||||||
|
} catch (err: any) {
|
||||||
|
error = `Failed to load library: ${err.message || err}`;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
stats = await getLibraryStats();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load stats:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch() {
|
||||||
|
await loadLibrary();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(imageId: string) {
|
||||||
|
if (!confirm('Permanently delete this image? It will be removed from all boards.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteLibraryImage(imageId);
|
||||||
|
await loadLibrary();
|
||||||
|
await loadStats();
|
||||||
|
} catch (err: any) {
|
||||||
|
error = `Failed to delete image: ${err.message || err}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddToBoard(image: LibraryImage) {
|
||||||
|
_selectedImage = image;
|
||||||
|
_showAddToBoard = true;
|
||||||
|
// TODO: Implement add to board modal
|
||||||
|
alert('Add to board feature coming soon!');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="library-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Image Library</h1>
|
||||||
|
{#if stats}
|
||||||
|
<div class="stats">
|
||||||
|
<span><strong>{stats.total_images}</strong> images</span>
|
||||||
|
<span><strong>{formatBytes(stats.total_size_bytes)}</strong> total</span>
|
||||||
|
<span><strong>{stats.total_board_references}</strong> board uses</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-bar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search images..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
on:keydown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
/>
|
||||||
|
<button class="btn-search" on:click={handleSearch}>Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error-message">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">Loading library...</div>
|
||||||
|
{:else if images.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No images in your library yet.</p>
|
||||||
|
<p>Upload images to boards to add them to your library.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="image-grid">
|
||||||
|
{#each images as image}
|
||||||
|
<div class="image-card">
|
||||||
|
{#if image.thumbnail_url}
|
||||||
|
<img src={image.thumbnail_url} alt={image.filename} class="thumbnail" />
|
||||||
|
{:else}
|
||||||
|
<div class="no-thumbnail">No preview</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="image-info">
|
||||||
|
<p class="filename">{image.filename}</p>
|
||||||
|
<p class="details">
|
||||||
|
{image.width}x{image.height} • {formatBytes(image.file_size)}
|
||||||
|
</p>
|
||||||
|
<p class="references">
|
||||||
|
Used on {image.reference_count} board{image.reference_count !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="image-actions">
|
||||||
|
<button class="btn-add" on:click={() => handleAddToBoard(image)}> Add to Board </button>
|
||||||
|
<button class="btn-delete" on:click={() => handleDelete(image.id)}> Delete </button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.library-page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-search {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: white;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-info {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filename {
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details,
|
||||||
|
.references {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0 1rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add,
|
||||||
|
.btn-delete {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
263
frontend/src/routes/shared/[token]/+page.svelte
Normal file
263
frontend/src/routes/shared/[token]/+page.svelte
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
getSharedBoard,
|
||||||
|
listComments,
|
||||||
|
createComment,
|
||||||
|
type Comment,
|
||||||
|
type SharedBoard,
|
||||||
|
} from '$lib/api/sharing';
|
||||||
|
|
||||||
|
const token = $page.params.token;
|
||||||
|
|
||||||
|
let board: SharedBoard | null = null;
|
||||||
|
let comments: Comment[] = [];
|
||||||
|
let loading = true;
|
||||||
|
let error = '';
|
||||||
|
let showCommentForm = false;
|
||||||
|
let commentAuthor = '';
|
||||||
|
let commentContent = '';
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadBoard();
|
||||||
|
await loadComments();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadBoard() {
|
||||||
|
try {
|
||||||
|
board = await getSharedBoard(token);
|
||||||
|
} catch (err: any) {
|
||||||
|
error = err.error || 'Failed to load board';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadComments() {
|
||||||
|
try {
|
||||||
|
comments = await listComments(token);
|
||||||
|
} catch (err) {
|
||||||
|
// Comments might not be available for view-only links
|
||||||
|
console.error('Failed to load comments:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitComment() {
|
||||||
|
if (!commentAuthor || !commentContent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createComment(token, {
|
||||||
|
author_name: commentAuthor,
|
||||||
|
content: commentContent,
|
||||||
|
});
|
||||||
|
commentContent = '';
|
||||||
|
showCommentForm = false;
|
||||||
|
await loadComments();
|
||||||
|
} catch (err: any) {
|
||||||
|
error = err.error || 'Failed to create comment';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="shared-board-container">
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">Loading board...</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="error-message">{error}</div>
|
||||||
|
{:else if board}
|
||||||
|
<div class="board-header">
|
||||||
|
<h1>{board.title}</h1>
|
||||||
|
{#if board.description}
|
||||||
|
<p class="description">{board.description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="board-content">
|
||||||
|
<p>Board ID: {board.id}</p>
|
||||||
|
<p class="note">This is a shared view of the board. You're viewing it as a guest.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comments-section">
|
||||||
|
<h2>Comments</h2>
|
||||||
|
|
||||||
|
{#if comments.length > 0}
|
||||||
|
<div class="comments-list">
|
||||||
|
{#each comments as comment}
|
||||||
|
<div class="comment">
|
||||||
|
<div class="comment-header">
|
||||||
|
<strong>{comment.author_name}</strong>
|
||||||
|
<span class="comment-date">
|
||||||
|
{new Date(comment.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="comment-content">{comment.content}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="no-comments">No comments yet.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !showCommentForm}
|
||||||
|
<button class="btn-add-comment" on:click={() => (showCommentForm = true)}>
|
||||||
|
Add Comment
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="comment-form">
|
||||||
|
<input type="text" placeholder="Your name" bind:value={commentAuthor} />
|
||||||
|
<textarea placeholder="Your comment" bind:value={commentContent} rows="3" />
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn-submit" on:click={handleSubmitComment}> Submit </button>
|
||||||
|
<button class="btn-cancel" on:click={() => (showCommentForm = false)}> Cancel </button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="error-message">Board not found</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.shared-board-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #ef4444;
|
||||||
|
background: #fee2e2;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-content {
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-date {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-comments {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-comment {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form input,
|
||||||
|
.comment-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit,
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user