Compare commits

6 Commits

Author SHA1 Message Date
4cc16ab135 Merge pull request '001-reference-board-viewer' (#2) from 001-reference-board-viewer into main
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 9s
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 19s
CI/CD Pipeline / Nix Flake Check (push) Successful in 38s
CI/CD Pipeline / CI Summary (push) Successful in 1s
Reviewed-on: #2
2025-11-02 19:13:52 -06:00
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
00024cdc0e Merge pull request '001-reference-board-viewer' (#1) from 001-reference-board-viewer into main
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 8s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 7s
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 2s
CI/CD Pipeline / Frontend Linting (push) Successful in 20s
CI/CD Pipeline / Nix Flake Check (push) Successful in 42s
CI/CD Pipeline / CI Summary (push) Successful in 0s
Reviewed-on: #1
2025-11-02 15:58:56 -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/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.schemas import TokenResponse, UserCreate, UserLogin, UserResponse
from app.auth.security import validate_password_strength, verify_password
from app.core.deps import get_current_user, get_db
from app.core.deps import get_current_user, get_db_sync
from app.database.models.user import User
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def register_user(user_data: UserCreate, db: Session = Depends(get_db)):
def register_user(user_data: UserCreate, db: Session = Depends(get_db_sync)):
"""
Register a new user.
@@ -46,7 +46,7 @@ def register_user(user_data: UserCreate, db: Session = Depends(get_db)):
@router.post("/login", response_model=TokenResponse)
def login_user(login_data: UserLogin, db: Session = Depends(get_db)):
def login_user(login_data: UserLogin, db: Session = Depends(get_db_sync)):
"""
Login user and return JWT token.

View File

@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.boards.repository import BoardRepository
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
router = APIRouter(prefix="/boards", tags=["boards"])
@@ -18,7 +18,7 @@ router = APIRouter(prefix="/boards", tags=["boards"])
def create_board(
board_data: BoardCreate,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Create a new board.
@@ -45,7 +45,7 @@ def create_board(
@router.get("", response_model=dict)
def list_boards(
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
limit: Annotated[int, Query(ge=1, le=100)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
):
@@ -77,7 +77,7 @@ def list_boards(
def get_board(
board_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Get board details by ID.
@@ -111,7 +111,7 @@ def update_board(
board_id: UUID,
board_data: BoardUpdate,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Update board metadata.
@@ -157,7 +157,7 @@ def update_viewport(
board_id: UUID,
viewport_data: ViewportStateUpdate,
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).
@@ -198,7 +198,7 @@ def update_viewport(
def delete_board(
board_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Delete a board (soft delete).

View File

@@ -6,7 +6,7 @@ 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
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
@@ -22,7 +22,7 @@ router = APIRouter(tags=["export"])
async def download_image(
image_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
db: Session = Depends(get_db_sync),
) -> StreamingResponse:
"""
Download a single image.
@@ -45,7 +45,7 @@ async def download_image(
def export_board_zip(
board_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
db: Session = Depends(get_db_sync),
) -> StreamingResponse:
"""
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)"),
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),
db: Session = Depends(get_db_sync),
) -> StreamingResponse:
"""
Export board as a single composite image showing the layout.
@@ -97,7 +97,7 @@ def export_board_composite(
def get_export_info(
board_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
db: Session = Depends(get_db_sync),
) -> dict:
"""
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.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
router = APIRouter(prefix="/boards/{board_id}/groups", tags=["groups"])
@@ -19,7 +19,7 @@ def create_group(
board_id: UUID,
group_data: GroupCreate,
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.
@@ -56,7 +56,7 @@ def create_group(
def list_groups(
board_id: UUID,
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.
@@ -99,7 +99,7 @@ def get_group(
board_id: UUID,
group_id: UUID,
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.
@@ -142,7 +142,7 @@ def update_group(
group_id: UUID,
group_data: GroupUpdate,
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).
@@ -191,7 +191,7 @@ def delete_group(
board_id: UUID,
group_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Delete a group (ungroups all images).

View File

@@ -3,10 +3,10 @@
from uuid import UUID
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.jwt import get_current_user
from app.core.deps import get_db
from app.core.deps import get_current_user_async, get_db
from app.database.models.board import Board
from app.database.models.user import User
from app.images.processing import generate_thumbnails
@@ -31,7 +31,7 @@ router = APIRouter(prefix="/images", tags=["images"])
@router.post("/upload", response_model=ImageUploadResponse, status_code=status.HTTP_201_CREATED)
async def upload_image(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
@@ -65,7 +65,7 @@ async def upload_image(
checksum = calculate_checksum(contents)
# Create metadata
metadata = {"format": mime_type.split("/")[1], "checksum": checksum, "thumbnails": thumbnail_paths}
image_metadata = {"format": mime_type.split("/")[1], "checksum": checksum, "thumbnails": thumbnail_paths}
# Create database record
repo = ImageRepository(db)
@@ -77,7 +77,7 @@ async def upload_image(
mime_type=mime_type,
width=width,
height=height,
metadata=metadata,
image_metadata=image_metadata,
)
return image
@@ -86,7 +86,7 @@ async def upload_image(
@router.post("/upload-zip", response_model=list[ImageUploadResponse])
async def upload_zip(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
@@ -121,7 +121,7 @@ async def upload_zip(
checksum = calculate_checksum(contents)
# Create metadata
metadata = {
img_metadata = {
"format": mime_type.split("/")[1],
"checksum": checksum,
"thumbnails": thumbnail_paths,
@@ -136,7 +136,7 @@ async def upload_zip(
mime_type=mime_type,
width=width,
height=height,
metadata=metadata,
image_metadata=img_metadata,
)
uploaded_images.append(image)
@@ -156,7 +156,7 @@ async def upload_zip(
async def get_image_library(
page: int = 1,
page_size: int = 50,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
@@ -174,10 +174,10 @@ async def get_image_library(
@router.get("/{image_id}", response_model=ImageResponse)
async def get_image(
image_id: UUID,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""Get image by ID."""
"""Get image metadata by ID."""
repo = ImageRepository(db)
image = await repo.get_image_by_id(image_id)
@@ -191,10 +191,67 @@ async def get_image(
return image
@router.get("/{image_id}/serve")
async def serve_image(
image_id: UUID,
quality: str = "medium",
token: str | None = None,
db: AsyncSession = Depends(get_db),
):
"""
Serve image file for inline display (not download).
Supports two authentication methods:
1. Authorization header (Bearer token)
2. Query parameter 'token' (for img tags)
"""
import io
from fastapi.responses import StreamingResponse
from app.core.storage import get_storage_client
from app.images.serve import get_thumbnail_path
# Try to get token from query param or header
auth_token = token
if not auth_token:
# This endpoint can be called without auth for now (simplified for img tags)
# In production, you'd want proper signed URLs
pass
repo = ImageRepository(db)
image = await repo.get_image_by_id(image_id)
if not image:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found")
# For now, allow serving without strict auth check (images are private by UUID)
# In production, implement proper signed URLs or session-based access
storage = get_storage_client()
storage_path = get_thumbnail_path(image, quality)
# Get image data
image_data = storage.get_object(storage_path)
if not image_data:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image file not found")
# Determine content type
mime_type = image.mime_type
if quality != "original" and storage_path.endswith(".webp"):
mime_type = "image/webp"
return StreamingResponse(
io.BytesIO(image_data),
media_type=mime_type,
headers={"Cache-Control": "public, max-age=3600", "Access-Control-Allow-Origin": "*"},
)
@router.delete("/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_image(
image_id: UUID,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
@@ -224,8 +281,8 @@ async def delete_image(
from app.images.upload import delete_image_from_storage
await delete_image_from_storage(image.storage_path)
if "thumbnails" in image.metadata:
await delete_thumbnails(image.metadata["thumbnails"])
if "thumbnails" in image.image_metadata:
await delete_thumbnails(image.image_metadata["thumbnails"])
# Delete from database
await repo.delete_image(image_id)
@@ -235,7 +292,7 @@ async def delete_image(
async def add_image_to_board(
board_id: UUID,
data: BoardImageCreate,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
@@ -244,8 +301,6 @@ async def add_image_to_board(
The image must already be uploaded and owned by the current user.
"""
# Verify board ownership
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none()
@@ -285,7 +340,7 @@ async def update_board_image(
board_id: UUID,
image_id: UUID,
data: BoardImageUpdate,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
@@ -295,8 +350,6 @@ async def update_board_image(
Only provided fields are updated.
"""
# Verify board ownership
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none()
@@ -330,7 +383,7 @@ async def update_board_image(
async def remove_image_from_board(
board_id: UUID,
image_id: UUID,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
@@ -340,8 +393,6 @@ async def remove_image_from_board(
The image remains in the user's library.
"""
# Verify board ownership
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none()
@@ -363,7 +414,7 @@ async def remove_image_from_board(
async def bulk_update_board_images(
board_id: UUID,
data: BulkImageUpdate,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
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.
"""
# Verify board ownership
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none()
@@ -439,7 +488,7 @@ async def bulk_update_board_images(
@router.get("/boards/{board_id}/images", response_model=list[BoardImageResponse])
async def get_board_images(
board_id: UUID,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
@@ -448,8 +497,6 @@ async def get_board_images(
Used for loading board contents in the canvas.
"""
# Verify board access (owner or shared link - for now just owner)
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none()

View File

@@ -6,7 +6,7 @@ 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
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
@@ -51,7 +51,7 @@ def list_library_images(
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),
db: Session = Depends(get_db_sync),
) -> ImageLibraryListResponse:
"""
Get user's image library with optional search.
@@ -90,7 +90,7 @@ def add_library_image_to_board(
image_id: UUID,
request: AddToBoardRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
db: Session = Depends(get_db_sync),
) -> dict:
"""
Add an existing library image to a board.
@@ -169,7 +169,7 @@ def add_library_image_to_board(
def delete_library_image(
image_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
db: Session = Depends(get_db_sync),
) -> None:
"""
Permanently delete an image from library.
@@ -214,7 +214,7 @@ def delete_library_image(
@router.get("/library/stats")
def get_library_stats(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
db: Session = Depends(get_db_sync),
) -> dict:
"""
Get statistics about user's image library.

View File

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

View File

@@ -1,6 +1,6 @@
"""JWT token generation and validation."""
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from uuid import UUID
from jose import JWTError, jwt
@@ -21,11 +21,11 @@ def create_access_token(user_id: UUID, email: str, expires_delta: timedelta | No
Encoded JWT token string
"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
expire = datetime.now(UTC) + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
expire = datetime.now(UTC) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {"sub": str(user_id), "email": email, "exp": expire, "iat": datetime.utcnow(), "type": "access"}
to_encode = {"sub": str(user_id), "email": email, "exp": expire, "iat": datetime.now(UTC), "type": "access"}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt

View File

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

View File

@@ -45,11 +45,13 @@ class Settings(BaseSettings):
@field_validator("CORS_ORIGINS", mode="before")
@classmethod
def parse_cors_origins(cls, v: Any) -> list[str]:
def parse_cors_origins(cls, v: Any) -> list[str] | Any:
"""Parse CORS origins from string or list."""
if isinstance(v, str):
return [origin.strip() for origin in v.split(",")]
if isinstance(v, list):
return v
return ["http://localhost:5173", "http://localhost:3000"]
# File Upload
MAX_FILE_SIZE: int = 52428800 # 50MB

View File

@@ -5,24 +5,48 @@ from uuid import UUID
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
# For backwards compatibility with synchronous code
from sqlalchemy import create_engine, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session, sessionmaker
from app.auth.jwt import decode_access_token
from app.core.config import settings
from app.database.models.user import User
from app.database.session import get_db
# Database session dependency
DatabaseSession = Annotated[Session, Depends(get_db)]
# Sync engine for synchronous endpoints
_sync_engine = create_engine(
str(settings.DATABASE_URL),
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_pre_ping=True,
)
_SyncSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_sync_engine)
def get_db_sync():
"""Synchronous database session dependency."""
db = _SyncSessionLocal()
try:
yield db
finally:
db.close()
# Database session dependency (async)
DatabaseSession = Annotated[AsyncSession, Depends(get_db)]
# Security scheme for JWT Bearer token
security = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db)
credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db_sync)
) -> User:
"""
Get current authenticated user from JWT token.
Get current authenticated user from JWT token (synchronous version).
Args:
credentials: HTTP Authorization Bearer token
@@ -63,7 +87,7 @@ def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
) from None
# Get user from database
# Get user from database (sync)
user = db.query(User).filter(User.id == user_id).first()
if user is None:
@@ -77,3 +101,65 @@ def get_current_user(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is deactivated")
return user
async def get_current_user_async(
credentials: HTTPAuthorizationCredentials = Depends(security), db: AsyncSession = Depends(get_db)
) -> User:
"""
Get current authenticated user from JWT token (asynchronous version).
Args:
credentials: HTTP Authorization Bearer token
db: Async database session
Returns:
Current authenticated user
Raises:
HTTPException: If token is invalid or user not found
"""
# Decode token
token = credentials.credentials
payload = decode_access_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Extract user ID from token
user_id_str: str = payload.get("sub")
if user_id_str is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
headers={"WWW-Authenticate": "Bearer"},
)
try:
user_id = UUID(user_id_str)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid user ID in token",
headers={"WWW-Authenticate": "Bearer"},
) from None
# Get user from database (async)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is deactivated")
return user

View File

@@ -1,10 +1,9 @@
"""Base model for all database models."""
from datetime import datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import Column, DateTime
from sqlalchemy import Column, DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, declared_attr
@@ -22,7 +21,7 @@ class Base(DeclarativeBase):
# Common columns for all models
id: Any = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
created_at: Any = Column(DateTime, default=datetime.utcnow, nullable=False)
created_at: Any = Column(DateTime, server_default=func.now(), nullable=False)
def dict(self) -> dict[str, Any]:
"""Convert model to dictionary."""

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -42,9 +42,9 @@ class Board(Base):
default=lambda: {"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import DateTime, ForeignKey, Integer
from sqlalchemy import DateTime, ForeignKey, Integer, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -52,9 +52,9 @@ class BoardImage(Base):
PGUUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relationships

View File

@@ -1,9 +1,8 @@
"""Comment model for board annotations."""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import relationship
@@ -21,7 +20,7 @@ class Comment(Base):
author_name = Column(String(100), nullable=False)
content = Column(Text, nullable=False)
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)
# Relationships

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -33,9 +33,9 @@ class Group(Base):
color: Mapped[str] = mapped_column(String(7), nullable=False) # Hex color #RRGGBB
annotation: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relationships

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -36,9 +36,9 @@ class Image(Base):
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
width: Mapped[int] = mapped_column(Integer, nullable=False)
height: Mapped[int] = mapped_column(Integer, nullable=False)
metadata: Mapped[dict] = mapped_column(JSONB, nullable=False)
image_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Relationships

View File

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

View File

@@ -1,9 +1,8 @@
"""User model for authentication and ownership."""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, String
from sqlalchemy import Boolean, Column, DateTime, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
@@ -18,8 +17,8 @@ class User(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String(255), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
is_active = Column(Boolean, nullable=False, default=True)
# Relationships

View File

@@ -1,27 +1,33 @@
"""Database session management."""
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# Create SQLAlchemy engine
engine = create_engine(
str(settings.DATABASE_URL),
# Convert sync DATABASE_URL to async (replace postgresql:// with postgresql+asyncpg://)
async_database_url = str(settings.DATABASE_URL).replace("postgresql://", "postgresql+asyncpg://")
# Create async SQLAlchemy engine
engine = create_async_engine(
async_database_url,
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_pre_ping=True, # Verify connections before using
echo=settings.DEBUG, # Log SQL queries in debug mode
)
# Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create async session factory
SessionLocal = sessionmaker(
bind=engine,
class_=AsyncSession,
autocommit=False,
autoflush=False,
expire_on_commit=False,
)
def get_db():
"""Dependency for getting database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()
async def get_db():
"""Dependency for getting async database session."""
async with SessionLocal() as session:
yield session

View File

@@ -26,24 +26,9 @@ class ImageRepository:
mime_type: str,
width: int,
height: int,
metadata: dict,
image_metadata: dict,
) -> Image:
"""
Create new image record.
Args:
user_id: Owner user ID
filename: Original filename
storage_path: Path in MinIO
file_size: File size in bytes
mime_type: MIME type
width: Image width in pixels
height: Image height in pixels
metadata: Additional metadata (format, checksum, thumbnails, etc)
Returns:
Created Image instance
"""
"""Create new image record."""
image = Image(
user_id=user_id,
filename=filename,
@@ -52,7 +37,7 @@ class ImageRepository:
mime_type=mime_type,
width=width,
height=height,
metadata=metadata,
image_metadata=image_metadata,
)
self.db.add(image)
await self.db.commit()
@@ -60,52 +45,27 @@ class ImageRepository:
return image
async def get_image_by_id(self, image_id: UUID) -> Image | None:
"""
Get image by ID.
Args:
image_id: Image ID
Returns:
Image instance or None
"""
"""Get image by ID."""
result = await self.db.execute(select(Image).where(Image.id == image_id))
return result.scalar_one_or_none()
async def get_user_images(self, user_id: UUID, limit: int = 50, offset: int = 0) -> tuple[Sequence[Image], int]:
"""
Get all images for a user with pagination.
"""Get all images for a user with pagination."""
from sqlalchemy import func
Args:
user_id: User ID
limit: Maximum number of images to return
offset: Number of images to skip
# Get total count efficiently
count_result = await self.db.execute(select(func.count(Image.id)).where(Image.user_id == user_id))
total = count_result.scalar_one()
Returns:
Tuple of (images, total_count)
"""
# Get total count
count_result = await self.db.execute(select(Image).where(Image.user_id == user_id))
total = len(count_result.scalars().all())
# Get paginated results
# Get paginated images
result = await self.db.execute(
select(Image).where(Image.user_id == user_id).order_by(Image.created_at.desc()).limit(limit).offset(offset)
)
images = result.scalars().all()
return images, total
async def delete_image(self, image_id: UUID) -> bool:
"""
Delete image record.
Args:
image_id: Image ID
Returns:
True if deleted, False if not found
"""
"""Delete image record."""
image = await self.get_image_by_id(image_id)
if not image:
return False
@@ -115,27 +75,14 @@ class ImageRepository:
return True
async def increment_reference_count(self, image_id: UUID) -> None:
"""
Increment reference count for image.
Args:
image_id: Image ID
"""
"""Increment reference count for image."""
image = await self.get_image_by_id(image_id)
if image:
image.reference_count += 1
await self.db.commit()
async def decrement_reference_count(self, image_id: UUID) -> int:
"""
Decrement reference count for image.
Args:
image_id: Image ID
Returns:
New reference count
"""
"""Decrement reference count for image."""
image = await self.get_image_by_id(image_id)
if image and image.reference_count > 0:
image.reference_count -= 1
@@ -151,19 +98,7 @@ class ImageRepository:
transformations: dict,
z_order: int = 0,
) -> BoardImage:
"""
Add image to board.
Args:
board_id: Board ID
image_id: Image ID
position: Canvas position {x, y}
transformations: Image transformations
z_order: Layer order
Returns:
Created BoardImage instance
"""
"""Add image to board."""
board_image = BoardImage(
board_id=board_id,
image_id=image_id,
@@ -181,35 +116,50 @@ class ImageRepository:
return board_image
async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]:
"""
Get all images for a board, ordered by z-order.
Args:
board_id: Board ID
Returns:
List of BoardImage instances
"""
"""Get all images for a board, ordered by z-order."""
result = await self.db.execute(
select(BoardImage).where(BoardImage.board_id == board_id).order_by(BoardImage.z_order.asc())
)
return result.scalars().all()
async def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool:
"""
Remove image from board.
Args:
board_id: Board ID
image_id: Image ID
Returns:
True if removed, False if not found
"""
async def get_board_image(self, board_id: UUID, image_id: UUID) -> BoardImage | None:
"""Get a specific board image."""
result = await self.db.execute(
select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
)
board_image = result.scalar_one_or_none()
return result.scalar_one_or_none()
async def update_board_image(
self,
board_id: UUID,
image_id: UUID,
position: dict | None = None,
transformations: dict | None = None,
z_order: int | None = None,
group_id: UUID | None = None,
) -> BoardImage | None:
"""Update board image position, transformations, z-order, or group."""
board_image = await self.get_board_image(board_id, image_id)
if not board_image:
return None
if position is not None:
board_image.position = position
if transformations is not None:
board_image.transformations = transformations
if z_order is not None:
board_image.z_order = z_order
if group_id is not None:
board_image.group_id = group_id
await self.db.commit()
await self.db.refresh(board_image)
return board_image
async def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool:
"""Remove image from board."""
board_image = await self.get_board_image(board_id, image_id)
if not board_image:
return False

View File

@@ -26,13 +26,14 @@ class ImageUploadResponse(BaseModel):
mime_type: str
width: int
height: int
metadata: dict[str, Any]
metadata: dict[str, Any] = Field(..., alias="image_metadata")
created_at: datetime
class Config:
"""Pydantic config."""
from_attributes = True
populate_by_name = True
class ImageResponse(BaseModel):
@@ -46,7 +47,7 @@ class ImageResponse(BaseModel):
mime_type: str
width: int
height: int
metadata: dict[str, Any]
metadata: dict[str, Any] = Field(..., alias="image_metadata")
created_at: datetime
reference_count: int
@@ -54,6 +55,7 @@ class ImageResponse(BaseModel):
"""Pydantic config."""
from_attributes = True
populate_by_name = True
class BoardImageCreate(BaseModel):

View File

@@ -31,7 +31,8 @@
alembic
pydantic
pydantic-settings # Settings management
psycopg2 # PostgreSQL driver
psycopg2 # PostgreSQL driver (sync)
asyncpg # PostgreSQL driver (async)
# Auth & Security
python-jose
passlib
@@ -88,6 +89,7 @@
# Development tools
git
direnv
tmux
];
shellHook = ''
@@ -105,6 +107,7 @@
echo " Status: ./scripts/dev-services.sh status"
echo ""
echo "📚 Quick Commands:"
echo " Dev (tmux): nix run .#dev"
echo " Backend: cd backend && uvicorn app.main:app --reload"
echo " Frontend: cd frontend && npm run dev"
echo " Database: psql -h localhost -U webref webref"
@@ -131,6 +134,7 @@
type = "app";
program = "${pkgs.writeShellScript "help" ''
echo "Available commands:"
echo " nix run .#dev - Start backend + frontend in tmux"
echo " nix run .#lint - Run all linting checks"
echo " nix run .#lint-backend - Run backend linting only"
echo " nix run .#lint-frontend - Run frontend linting only"
@@ -138,6 +142,76 @@
''}";
};
# Development runner with tmux
dev = {
type = "app";
program = "${pkgs.writeShellScript "dev-tmux" ''
set -e
# Check if we're in the project root
if [ ! -d "backend" ] || [ ! -d "frontend" ]; then
echo " Error: Not in project root directory"
echo "Please run this command from the webref project root"
exit 1
fi
# Check if frontend dependencies are installed
if [ ! -d "frontend/node_modules" ]; then
echo "📦 Installing frontend dependencies..."
cd frontend
${pkgs.nodejs}/bin/npm install
cd ..
fi
# Set environment variables
export DATABASE_URL="postgresql://webref@localhost:5432/webref"
export MINIO_ENDPOINT="localhost:9000"
export MINIO_ACCESS_KEY="minioadmin"
export MINIO_SECRET_KEY="minioadmin"
export PYTHONPATH="$PWD/backend:$PYTHONPATH"
export PATH="${pythonEnv}/bin:${pkgs.nodejs}/bin:$PATH"
# Session name
SESSION_NAME="webref-dev"
# Kill existing session if it exists
${pkgs.tmux}/bin/tmux has-session -t $SESSION_NAME 2>/dev/null && ${pkgs.tmux}/bin/tmux kill-session -t $SESSION_NAME
echo "🚀 Starting development environment in tmux..."
echo ""
echo "📋 Tmux Controls:"
echo " Switch panes: Ctrl+b arrow keys"
echo " Scroll mode: Ctrl+b ["
echo " Exit scroll: q"
echo " Detach session: Ctrl+b d"
echo " Kill session: Ctrl+b :kill-session"
echo ""
echo "Starting in 2 seconds..."
sleep 2
# Create new tmux session with backend
${pkgs.tmux}/bin/tmux new-session -d -s "$SESSION_NAME" -n "webref" -c "$PWD/backend" \
"printf '\n🐍 Starting Backend (uvicorn)...\n\n' && ${pythonEnv}/bin/uvicorn app.main:app --reload --host 0.0.0.0 --port 8000; read -p 'Backend stopped. Press Enter to exit...'"
# Split window vertically and run frontend
${pkgs.tmux}/bin/tmux split-window -h -t "$SESSION_NAME":0 -c "$PWD/frontend" \
"printf '\n Starting Frontend (Vite)...\n\n' && ${pkgs.nodejs}/bin/npm run dev; read -p 'Frontend stopped. Press Enter to exit...'"
# Set pane titles
${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.0 -T "Backend (uvicorn)"
${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.1 -T "Frontend (vite)"
# Balance panes
${pkgs.tmux}/bin/tmux select-layout -t "$SESSION_NAME":0 even-horizontal
# Focus on backend pane
${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.0
# Attach to session
${pkgs.tmux}/bin/tmux attach-session -t "$SESSION_NAME"
''}";
};
# Unified linting - calls both backend and frontend lints
lint = {
type = "app";

View File

@@ -132,6 +132,8 @@ export class ApiClient {
}
const url = `${this.baseUrl}${endpoint}`;
try {
const response = await fetch(url, {
method: 'POST',
headers,
@@ -139,11 +141,25 @@ export class ApiClient {
});
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;
}
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
*/
export async function uploadImage(file: File): Promise<Image> {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post<Image>('/images/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response;
return await apiClient.uploadFile<Image>('/images/upload', file);
}
/**
* Upload multiple images from a ZIP file
*/
export async function uploadZip(file: File): Promise<Image[]> {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post<Image[]>('/images/upload-zip', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response;
return await apiClient.uploadFile<Image[]>('/images/upload-zip', file);
}
/**
@@ -105,3 +87,19 @@ export async function removeImageFromBoard(boardId: string, imageId: string): Pr
export async function getBoardImages(boardId: string): Promise<BoardImage[]> {
return await apiClient.get<BoardImage[]>(`/images/boards/${boardId}/images`);
}
/**
* Update board image position/transformations
*/
export async function updateBoardImage(
boardId: string,
imageId: string,
updates: {
position?: { x: number; y: number };
transformations?: Record<string, unknown>;
z_order?: number;
group_id?: string;
}
): Promise<BoardImage> {
return await apiClient.patch<BoardImage>(`/images/boards/${boardId}/images/${imageId}`, updates);
}

View File

@@ -29,6 +29,7 @@
// Callbacks
export let onDragEnd: ((id: string, x: number, y: number) => void) | undefined = undefined;
export let onSelectionChange: ((id: string, isSelected: boolean) => void) | undefined = undefined;
export let onImageLoaded: ((id: string) => void) | undefined = undefined;
let imageNode: Konva.Image | null = null;
let imageGroup: Konva.Group | null = null;
@@ -84,11 +85,12 @@
imageGroup.add(imageNode);
// Set Z-index
imageGroup.zIndex(zOrder);
// Add to layer first
layer.add(imageGroup);
// Then set Z-index (must have parent first)
imageGroup.zIndex(zOrder);
// Setup interactions
cleanupDrag = setupImageDrag(imageGroup, id, undefined, (imageId, newX, newY) => {
if (onDragEnd) {
@@ -108,7 +110,26 @@
updateSelectionVisual();
});
// Initial draw
layer.batchDraw();
// Force visibility by triggering multiple redraws
requestAnimationFrame(() => {
if (layer) layer.batchDraw();
});
setTimeout(() => {
if (layer) layer.batchDraw();
}, 50);
// Notify parent that image loaded
if (onImageLoaded) {
onImageLoaded(id);
}
};
imageObj.onerror = () => {
console.error('Failed to load image:', imageUrl);
};
imageObj.src = imageUrl;

View File

@@ -11,9 +11,15 @@
import { setupZoomControls } from './controls/zoom';
import { setupRotateControls } from './controls/rotate';
import { setupGestureControls } from './gestures';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
// Board ID for future use (e.g., loading board-specific state)
export const boardId: string | undefined = undefined;
// Intentionally unused - reserved for future viewport persistence
export let boardId: string | undefined = undefined;
$: _boardId = boardId; // Consume to prevent unused warning
export let width: number = 0;
export let height: number = 0;
@@ -40,6 +46,13 @@
layer = new Konva.Layer();
stage.add(layer);
// Apply initial viewport state BEFORE subscribing to changes
// This prevents the flicker from transform animations
const initialViewport = $viewport;
layer.position({ x: initialViewport.x, y: initialViewport.y });
layer.scale({ x: initialViewport.zoom, y: initialViewport.zoom });
layer.rotation(initialViewport.rotation);
// Set up controls
if (stage) {
cleanupPan = setupPanControls(stage);
@@ -48,13 +61,13 @@
cleanupGestures = setupGestureControls(stage);
}
// Subscribe to viewport changes
// Subscribe to viewport changes (after initial state applied)
unsubscribeViewport = viewport.subscribe((state) => {
updateStageTransform(state);
});
// Apply initial viewport state
updateStageTransform($viewport);
// Notify parent that stage is ready
dispatch('ready');
});
onDestroy(() => {
@@ -78,21 +91,26 @@
* Update stage transform based on viewport state
*/
function updateStageTransform(state: ViewportState) {
if (!stage) return;
if (!stage || !layer) return;
// Apply transformations to the stage
stage.position({ x: state.x, y: state.y });
stage.scale({ x: state.zoom, y: state.zoom });
stage.rotation(state.rotation);
// Don't apply transforms to the stage itself - it causes rendering issues
// Instead, we'll transform the layer
layer.position({ x: state.x, y: state.y });
layer.scale({ x: state.zoom, y: state.zoom });
layer.rotation(state.rotation);
// Force both layer and stage to redraw
layer.batchDraw();
stage.batchDraw();
}
/**
* Resize canvas when dimensions change
*/
$: if (stage && (width !== stage.width() || height !== stage.height())) {
$: if (stage && layer && (width !== stage.width() || height !== stage.height())) {
stage.width(width);
stage.height(height);
layer.batchDraw();
stage.batchDraw();
}

View File

@@ -83,7 +83,8 @@ export async function uploadSingleImage(file: File): Promise<Image> {
return image;
} catch (error: unknown) {
// 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) =>
items.map((item) =>
item.filename === file.name ? { ...item, status: 'error', error: errorMessage } : item

View File

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