Compare commits

...

4 Commits

Author SHA1 Message Date
Danilo Reyes
a8315d03fd fix until the canvas sort of works
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 12s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / VM Test - security (push) Successful in 8s
CI/CD Pipeline / Backend Linting (push) Successful in 4s
CI/CD Pipeline / Frontend Linting (push) Successful in 30s
CI/CD Pipeline / Nix Flake Check (push) Successful in 43s
CI/CD Pipeline / VM Test - backend-integration (pull_request) Successful in 4s
CI/CD Pipeline / VM Test - full-stack (pull_request) Successful in 2s
CI/CD Pipeline / VM Test - performance (pull_request) Successful in 2s
CI/CD Pipeline / VM Test - security (pull_request) Successful in 2s
CI/CD Pipeline / Backend Linting (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Linting (pull_request) Successful in 17s
CI/CD Pipeline / Nix Flake Check (pull_request) Successful in 38s
CI/CD Pipeline / CI Summary (push) Successful in 1s
CI/CD Pipeline / CI Summary (pull_request) Successful in 1s
2025-11-02 19:13:08 -06:00
Danilo Reyes
ff1c29c66a fix part 3 2025-11-02 18:32:20 -06:00
Danilo Reyes
209b6d9f18 fix part 2 2025-11-02 18:23:10 -06:00
Danilo Reyes
376ac1dec9 fix part 1 2025-11-02 18:09:07 -06:00
41 changed files with 3409 additions and 271 deletions

View File

@@ -0,0 +1 @@
/nix/store/92khy67bgrzx85f6052pnw7xrs2jk1v6-source

View File

@@ -0,0 +1 @@
/nix/store/lhn3s31zbiq1syclv0rk94bn5g74750c-source

View File

@@ -0,0 +1 @@
/nix/store/xjjq52iwslhz6lbc621a31v0nfdhr5ks-source

View File

@@ -0,0 +1 @@
/nix/store/zzxxnkdqc6rdycxkylwrs2pg8ahj3cny-source

View File

@@ -0,0 +1 @@
/nix/store/xxizbrvv0ysnp79c429sgsa7g5vwqbr3-nix-shell-env

File diff suppressed because one or more lines are too long

1
.env.example Normal file
View File

@@ -0,0 +1 @@
SECRET_KEY=xM5coyysuo8LZJtNsytgP7OWiKEgHLL75-MGXWzYlxo

2
.gitignore vendored
View File

@@ -98,4 +98,4 @@ frontend/dist/
!.specify/templates/ !.specify/templates/
!.specify/memory/ !.specify/memory/
.direnv/ .direnv/backend/.env

View File

@@ -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.

View File

@@ -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).

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
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.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.image import Image from app.database.models.image import Image
@@ -22,7 +22,7 @@ router = APIRouter(tags=["export"])
async def download_image( async def download_image(
image_id: UUID, image_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Download a single image. Download a single image.
@@ -45,7 +45,7 @@ async def download_image(
def export_board_zip( def export_board_zip(
board_id: UUID, board_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Export all images from a board as a ZIP file. Export all images from a board as a ZIP file.
@@ -70,7 +70,7 @@ def export_board_composite(
scale: float = Query(1.0, ge=0.5, le=4.0, description="Resolution scale (0.5x to 4x)"), 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)"), format: str = Query("PNG", regex="^(PNG|JPEG)$", description="Output format (PNG or JPEG)"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Export board as a single composite image showing the layout. Export board as a single composite image showing the layout.
@@ -97,7 +97,7 @@ def export_board_composite(
def get_export_info( def get_export_info(
board_id: UUID, board_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> dict: ) -> dict:
""" """
Get information about board export (image count, estimated size). Get information about board export (image count, estimated size).

View File

@@ -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 GroupCreate, GroupResponse, GroupUpdate from app.boards.schemas import GroupCreate, GroupResponse, GroupUpdate
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/{board_id}/groups", tags=["groups"]) router = APIRouter(prefix="/boards/{board_id}/groups", tags=["groups"])
@@ -19,7 +19,7 @@ def create_group(
board_id: UUID, board_id: UUID,
group_data: GroupCreate, group_data: GroupCreate,
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 group on a board. Create a new group on a board.
@@ -56,7 +56,7 @@ def create_group(
def list_groups( def list_groups(
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)],
): ):
""" """
List all groups on a board. List all groups on a board.
@@ -99,7 +99,7 @@ def get_group(
board_id: UUID, board_id: UUID,
group_id: UUID, group_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 group details by ID. Get group details by ID.
@@ -142,7 +142,7 @@ def update_group(
group_id: UUID, group_id: UUID,
group_data: GroupUpdate, group_data: GroupUpdate,
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 group metadata (name, color, annotation). Update group metadata (name, color, annotation).
@@ -191,7 +191,7 @@ def delete_group(
board_id: UUID, board_id: UUID,
group_id: UUID, group_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 group (ungroups all images). Delete a group (ungroups all images).

View File

@@ -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()

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
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.board_image import BoardImage from app.database.models.board_image import BoardImage
from app.database.models.image import Image from app.database.models.image import Image
from app.database.models.user import User from app.database.models.user import User
@@ -51,7 +51,7 @@ def list_library_images(
limit: int = Query(50, ge=1, le=100, description="Results per page"), limit: int = Query(50, ge=1, le=100, description="Results per page"),
offset: int = Query(0, ge=0, description="Pagination offset"), offset: int = Query(0, ge=0, description="Pagination offset"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> ImageLibraryListResponse: ) -> ImageLibraryListResponse:
""" """
Get user's image library with optional search. Get user's image library with optional search.
@@ -90,7 +90,7 @@ def add_library_image_to_board(
image_id: UUID, image_id: UUID,
request: AddToBoardRequest, request: AddToBoardRequest,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> dict: ) -> dict:
""" """
Add an existing library image to a board. Add an existing library image to a board.
@@ -169,7 +169,7 @@ def add_library_image_to_board(
def delete_library_image( def delete_library_image(
image_id: UUID, image_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> None: ) -> None:
""" """
Permanently delete an image from library. Permanently delete an image from library.
@@ -214,7 +214,7 @@ def delete_library_image(
@router.get("/library/stats") @router.get("/library/stats")
def get_library_stats( def get_library_stats(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> dict: ) -> dict:
""" """
Get statistics about user's image library. Get statistics about user's image library.

View File

@@ -1,6 +1,6 @@
"""Board sharing API endpoints.""" """Board sharing API endpoints."""
from datetime import datetime from datetime import UTC, datetime
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -14,7 +14,7 @@ from app.boards.schemas import (
ShareLinkResponse, ShareLinkResponse,
) )
from app.boards.sharing import generate_secure_token from app.boards.sharing import generate_secure_token
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.board import Board from app.database.models.board import Board
from app.database.models.comment import Comment from app.database.models.comment import Comment
from app.database.models.share_link import ShareLink from app.database.models.share_link import ShareLink
@@ -54,7 +54,7 @@ def validate_share_link(token: str, db: Session, required_permission: str = "vie
) )
# Check expiration # Check expiration
if share_link.expires_at and share_link.expires_at < datetime.utcnow(): if share_link.expires_at and share_link.expires_at < datetime.now(UTC):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Share link has expired", detail="Share link has expired",
@@ -69,7 +69,7 @@ def validate_share_link(token: str, db: Session, required_permission: str = "vie
# Update access tracking # Update access tracking
share_link.access_count += 1 share_link.access_count += 1
share_link.last_accessed_at = datetime.utcnow() share_link.last_accessed_at = datetime.now(UTC)
db.commit() db.commit()
return share_link return share_link
@@ -80,7 +80,7 @@ def create_share_link(
board_id: UUID, board_id: UUID,
share_link_data: ShareLinkCreate, share_link_data: ShareLinkCreate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> ShareLinkResponse: ) -> ShareLinkResponse:
""" """
Create a new share link for a board. Create a new share link for a board.
@@ -117,7 +117,7 @@ def create_share_link(
def list_share_links( def list_share_links(
board_id: UUID, board_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> list[ShareLinkResponse]: ) -> list[ShareLinkResponse]:
""" """
List all share links for a board. List all share links for a board.
@@ -144,7 +144,7 @@ def revoke_share_link(
board_id: UUID, board_id: UUID,
link_id: UUID, link_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> None: ) -> None:
""" """
Revoke (soft delete) a share link. Revoke (soft delete) a share link.
@@ -176,7 +176,7 @@ def revoke_share_link(
@router.get("/shared/{token}", response_model=BoardDetail) @router.get("/shared/{token}", response_model=BoardDetail)
def get_shared_board( def get_shared_board(
token: str, token: str,
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> BoardDetail: ) -> BoardDetail:
""" """
Access a shared board via token. Access a shared board via token.
@@ -202,7 +202,7 @@ def get_shared_board(
def create_comment( def create_comment(
token: str, token: str,
comment_data: CommentCreate, comment_data: CommentCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> CommentResponse: ) -> CommentResponse:
""" """
Create a comment on a shared board. Create a comment on a shared board.
@@ -230,7 +230,7 @@ def create_comment(
@router.get("/shared/{token}/comments", response_model=list[CommentResponse]) @router.get("/shared/{token}/comments", response_model=list[CommentResponse])
def list_comments( def list_comments(
token: str, token: str,
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> list[CommentResponse]: ) -> list[CommentResponse]:
""" """
List all comments on a shared board. List all comments on a shared board.
@@ -255,7 +255,7 @@ def list_comments(
def list_board_comments( def list_board_comments(
board_id: UUID, board_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> list[CommentResponse]: ) -> list[CommentResponse]:
""" """
List all comments on a board (owner view). List all comments on a board (owner view).

View File

@@ -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

View File

@@ -2,7 +2,7 @@
import secrets import secrets
import string import string
from datetime import datetime from datetime import UTC, datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -53,12 +53,12 @@ def validate_share_link_token(token: str, db: Session) -> ShareLink | None:
return None return None
# Check expiration # Check expiration
if share_link.expires_at and share_link.expires_at < datetime.utcnow(): if share_link.expires_at and share_link.expires_at < datetime.now(UTC):
return None return None
# Update access tracking # Update access tracking
share_link.access_count += 1 share_link.access_count += 1
share_link.last_accessed_at = datetime.utcnow() share_link.last_accessed_at = datetime.now(UTC)
db.commit() db.commit()
return share_link return share_link

View File

@@ -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(",")]
if isinstance(v, list):
return v 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

View File

@@ -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

View File

@@ -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."""

View File

@@ -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
@@ -42,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)

View File

@@ -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

View File

@@ -1,9 +1,8 @@
"""Comment model for board annotations.""" """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
@@ -21,7 +20,7 @@ class Comment(Base):
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 reference 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

View File

@@ -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

View File

@@ -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

View File

@@ -1,9 +1,8 @@
"""ShareLink model for board sharing functionality.""" """ShareLink model for board sharing functionality."""
import uuid import uuid
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@@ -19,7 +18,7 @@ class ShareLink(Base):
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False) board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False)
token = Column(String(64), unique=True, nullable=False, index=True) token = Column(String(64), unique=True, nullable=False, index=True)
permission_level = Column(String(20), nullable=False) # 'view-only' or 'view-comment' permission_level = Column(String(20), nullable=False) # 'view-only' or 'view-comment'
created_at = Column(DateTime, nullable=False, default=datetime.utcnow) created_at = Column(DateTime, nullable=False, server_default=func.now())
expires_at = Column(DateTime, nullable=True) expires_at = Column(DateTime, nullable=True)
last_accessed_at = Column(DateTime, nullable=True) last_accessed_at = Column(DateTime, nullable=True)
access_count = Column(Integer, nullable=False, default=0) access_count = Column(Integer, nullable=False, default=0)

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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):

View File

@@ -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";

View File

@@ -132,6 +132,8 @@ export class ApiClient {
} }
const url = `${this.baseUrl}${endpoint}`; const url = `${this.baseUrl}${endpoint}`;
try {
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers, headers,
@@ -139,11 +141,25 @@ export class ApiClient {
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); 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; throw error;
} }
return response.json(); 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;
}
} }
} }

View File

@@ -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);
}

View File

@@ -29,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;
@@ -84,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) {
@@ -108,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;

View File

@@ -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();
} }

View File

@@ -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

View File

@@ -63,10 +63,8 @@ export function getThumbnailUrl(
imageId: string, imageId: string,
quality: 'low' | 'medium' | 'high' | 'original' = 'medium' quality: 'low' | 'medium' | 'high' | 'original' = 'medium'
): string { ): string {
if (quality === 'original') { const apiBase = 'http://localhost:8000/api/v1';
return `/api/v1/images/${imageId}/original`; return `${apiBase}/images/${imageId}/serve?quality=${quality}`;
}
return `/api/v1/images/${imageId}/thumbnail/${quality}`;
} }
/** /**

View 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>

View File

@@ -0,0 +1,2 @@
// Disable server-side rendering for the root page
export const ssr = false;

View 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>

11
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 5173,
strictPort: false,
},
});