Compare commits
5 Commits
00024cdc0e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cc16ab135 | |||
|
|
a8315d03fd | ||
|
|
ff1c29c66a | ||
|
|
209b6d9f18 | ||
|
|
376ac1dec9 |
1
.direnv/flake-inputs/92khy67bgrzx85f6052pnw7xrs2jk1v6-source
Symbolic link
1
.direnv/flake-inputs/92khy67bgrzx85f6052pnw7xrs2jk1v6-source
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/92khy67bgrzx85f6052pnw7xrs2jk1v6-source
|
||||||
1
.direnv/flake-inputs/lhn3s31zbiq1syclv0rk94bn5g74750c-source
Symbolic link
1
.direnv/flake-inputs/lhn3s31zbiq1syclv0rk94bn5g74750c-source
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/lhn3s31zbiq1syclv0rk94bn5g74750c-source
|
||||||
1
.direnv/flake-inputs/xjjq52iwslhz6lbc621a31v0nfdhr5ks-source
Symbolic link
1
.direnv/flake-inputs/xjjq52iwslhz6lbc621a31v0nfdhr5ks-source
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/xjjq52iwslhz6lbc621a31v0nfdhr5ks-source
|
||||||
1
.direnv/flake-inputs/zzxxnkdqc6rdycxkylwrs2pg8ahj3cny-source
Symbolic link
1
.direnv/flake-inputs/zzxxnkdqc6rdycxkylwrs2pg8ahj3cny-source
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/zzxxnkdqc6rdycxkylwrs2pg8ahj3cny-source
|
||||||
1
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa
Symbolic link
1
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/xxizbrvv0ysnp79c429sgsa7g5vwqbr3-nix-shell-env
|
||||||
2163
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc
Normal file
2163
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc
Normal file
File diff suppressed because one or more lines are too long
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
SECRET_KEY=xM5coyysuo8LZJtNsytgP7OWiKEgHLL75-MGXWzYlxo
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -98,4 +98,4 @@ frontend/dist/
|
|||||||
!.specify/templates/
|
!.specify/templates/
|
||||||
!.specify/memory/
|
!.specify/memory/
|
||||||
|
|
||||||
.direnv/
|
.direnv/backend/.env
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ from app.auth.jwt import create_access_token
|
|||||||
from app.auth.repository import UserRepository
|
from app.auth.repository import UserRepository
|
||||||
from app.auth.schemas import TokenResponse, UserCreate, UserLogin, UserResponse
|
from app.auth.schemas import TokenResponse, UserCreate, UserLogin, UserResponse
|
||||||
from app.auth.security import validate_password_strength, verify_password
|
from app.auth.security import validate_password_strength, verify_password
|
||||||
from app.core.deps import get_current_user, get_db
|
from app.core.deps import get_current_user, get_db_sync
|
||||||
from app.database.models.user import User
|
from app.database.models.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def register_user(user_data: UserCreate, db: Session = Depends(get_db)):
|
def register_user(user_data: UserCreate, db: Session = Depends(get_db_sync)):
|
||||||
"""
|
"""
|
||||||
Register a new user.
|
Register a new user.
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ def register_user(user_data: UserCreate, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
def login_user(login_data: UserLogin, db: Session = Depends(get_db)):
|
def login_user(login_data: UserLogin, db: Session = Depends(get_db_sync)):
|
||||||
"""
|
"""
|
||||||
Login user and return JWT token.
|
Login user and return JWT token.
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.boards.repository import BoardRepository
|
from app.boards.repository import BoardRepository
|
||||||
from app.boards.schemas import BoardCreate, BoardDetail, BoardSummary, BoardUpdate, ViewportStateUpdate
|
from app.boards.schemas import BoardCreate, BoardDetail, BoardSummary, BoardUpdate, ViewportStateUpdate
|
||||||
from app.core.deps import get_current_user, get_db
|
from app.core.deps import get_current_user, get_db_sync
|
||||||
from app.database.models.user import User
|
from app.database.models.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/boards", tags=["boards"])
|
router = APIRouter(prefix="/boards", tags=["boards"])
|
||||||
@@ -18,7 +18,7 @@ router = APIRouter(prefix="/boards", tags=["boards"])
|
|||||||
def create_board(
|
def create_board(
|
||||||
board_data: BoardCreate,
|
board_data: BoardCreate,
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db_sync)],
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Create a new board.
|
Create a new board.
|
||||||
@@ -45,7 +45,7 @@ def create_board(
|
|||||||
@router.get("", response_model=dict)
|
@router.get("", response_model=dict)
|
||||||
def list_boards(
|
def list_boards(
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db_sync)],
|
||||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||||
offset: Annotated[int, Query(ge=0)] = 0,
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
):
|
):
|
||||||
@@ -77,7 +77,7 @@ def list_boards(
|
|||||||
def get_board(
|
def get_board(
|
||||||
board_id: UUID,
|
board_id: UUID,
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db_sync)],
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get board details by ID.
|
Get board details by ID.
|
||||||
@@ -111,7 +111,7 @@ def update_board(
|
|||||||
board_id: UUID,
|
board_id: UUID,
|
||||||
board_data: BoardUpdate,
|
board_data: BoardUpdate,
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db_sync)],
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update board metadata.
|
Update board metadata.
|
||||||
@@ -157,7 +157,7 @@ def update_viewport(
|
|||||||
board_id: UUID,
|
board_id: UUID,
|
||||||
viewport_data: ViewportStateUpdate,
|
viewport_data: ViewportStateUpdate,
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db_sync)],
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update board viewport state only (optimized for frequent updates).
|
Update board viewport state only (optimized for frequent updates).
|
||||||
@@ -198,7 +198,7 @@ def update_viewport(
|
|||||||
def delete_board(
|
def delete_board(
|
||||||
board_id: UUID,
|
board_id: UUID,
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db_sync)],
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Delete a board (soft delete).
|
Delete a board (soft delete).
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
"""Base model for all database models."""
|
"""Base model for all database models."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import Column, DateTime
|
from sqlalchemy import Column, DateTime, func
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import DeclarativeBase, declared_attr
|
from sqlalchemy.orm import DeclarativeBase, declared_attr
|
||||||
|
|
||||||
@@ -22,7 +21,7 @@ class Base(DeclarativeBase):
|
|||||||
|
|
||||||
# Common columns for all models
|
# Common columns for all models
|
||||||
id: Any = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
|
id: Any = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||||
created_at: Any = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at: Any = Column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
def dict(self) -> dict[str, Any]:
|
def dict(self) -> dict[str, Any]:
|
||||||
"""Convert model to dictionary."""
|
"""Convert model to dictionary."""
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
|
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer
|
from sqlalchemy import DateTime, ForeignKey, Integer, func
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
@@ -52,9 +52,9 @@ class BoardImage(Base):
|
|||||||
PGUUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
|
PGUUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
|
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Comment model for board 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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -33,9 +33,9 @@ class Group(Base):
|
|||||||
color: Mapped[str] = mapped_column(String(7), nullable=False) # Hex color #RRGGBB
|
color: Mapped[str] = mapped_column(String(7), nullable=False) # Hex color #RRGGBB
|
||||||
annotation: Mapped[str | None] = mapped_column(Text, nullable=True)
|
annotation: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
|
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String
|
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, func
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
@@ -36,9 +36,9 @@ class Image(Base):
|
|||||||
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
width: Mapped[int] = mapped_column(Integer, nullable=False)
|
width: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
height: Mapped[int] = mapped_column(Integer, nullable=False)
|
height: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
metadata: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
image_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
|
||||||
reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|||||||
@@ -1,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)
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""User model for authentication and ownership."""
|
"""User model for authentication and ownership."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Column, DateTime, String
|
from sqlalchemy import Boolean, Column, DateTime, String, func
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
@@ -18,8 +17,8 @@ class User(Base):
|
|||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||||
password_hash = Column(String(255), nullable=False)
|
password_hash = Column(String(255), nullable=False)
|
||||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||||
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
|
||||||
is_active = Column(Boolean, nullable=False, default=True)
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|||||||
@@ -1,27 +1,33 @@
|
|||||||
"""Database session management."""
|
"""Database session management."""
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
# Create SQLAlchemy engine
|
# Convert sync DATABASE_URL to async (replace postgresql:// with postgresql+asyncpg://)
|
||||||
engine = create_engine(
|
async_database_url = str(settings.DATABASE_URL).replace("postgresql://", "postgresql+asyncpg://")
|
||||||
str(settings.DATABASE_URL),
|
|
||||||
|
# Create async SQLAlchemy engine
|
||||||
|
engine = create_async_engine(
|
||||||
|
async_database_url,
|
||||||
pool_size=settings.DATABASE_POOL_SIZE,
|
pool_size=settings.DATABASE_POOL_SIZE,
|
||||||
max_overflow=settings.DATABASE_MAX_OVERFLOW,
|
max_overflow=settings.DATABASE_MAX_OVERFLOW,
|
||||||
pool_pre_ping=True, # Verify connections before using
|
pool_pre_ping=True, # Verify connections before using
|
||||||
echo=settings.DEBUG, # Log SQL queries in debug mode
|
echo=settings.DEBUG, # Log SQL queries in debug mode
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create session factory
|
# Create async session factory
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(
|
||||||
|
bind=engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
autocommit=False,
|
||||||
|
autoflush=False,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
async def get_db():
|
||||||
"""Dependency for getting database session."""
|
"""Dependency for getting async database session."""
|
||||||
db = SessionLocal()
|
async with SessionLocal() as session:
|
||||||
try:
|
yield session
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|||||||
@@ -26,24 +26,9 @@ class ImageRepository:
|
|||||||
mime_type: str,
|
mime_type: str,
|
||||||
width: int,
|
width: int,
|
||||||
height: int,
|
height: int,
|
||||||
metadata: dict,
|
image_metadata: dict,
|
||||||
) -> Image:
|
) -> Image:
|
||||||
"""
|
"""Create new image record."""
|
||||||
Create new image record.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: Owner user ID
|
|
||||||
filename: Original filename
|
|
||||||
storage_path: Path in MinIO
|
|
||||||
file_size: File size in bytes
|
|
||||||
mime_type: MIME type
|
|
||||||
width: Image width in pixels
|
|
||||||
height: Image height in pixels
|
|
||||||
metadata: Additional metadata (format, checksum, thumbnails, etc)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Created Image instance
|
|
||||||
"""
|
|
||||||
image = Image(
|
image = Image(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
filename=filename,
|
filename=filename,
|
||||||
@@ -52,7 +37,7 @@ class ImageRepository:
|
|||||||
mime_type=mime_type,
|
mime_type=mime_type,
|
||||||
width=width,
|
width=width,
|
||||||
height=height,
|
height=height,
|
||||||
metadata=metadata,
|
image_metadata=image_metadata,
|
||||||
)
|
)
|
||||||
self.db.add(image)
|
self.db.add(image)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
@@ -60,52 +45,27 @@ class ImageRepository:
|
|||||||
return image
|
return image
|
||||||
|
|
||||||
async def get_image_by_id(self, image_id: UUID) -> Image | None:
|
async def get_image_by_id(self, image_id: UUID) -> Image | None:
|
||||||
"""
|
"""Get image by ID."""
|
||||||
Get image by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_id: Image ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Image instance or None
|
|
||||||
"""
|
|
||||||
result = await self.db.execute(select(Image).where(Image.id == image_id))
|
result = await self.db.execute(select(Image).where(Image.id == image_id))
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def get_user_images(self, user_id: UUID, limit: int = 50, offset: int = 0) -> tuple[Sequence[Image], int]:
|
async def get_user_images(self, user_id: UUID, limit: int = 50, offset: int = 0) -> tuple[Sequence[Image], int]:
|
||||||
"""
|
"""Get all images for a user with pagination."""
|
||||||
Get all images for a user with pagination.
|
from sqlalchemy import func
|
||||||
|
|
||||||
Args:
|
# Get total count efficiently
|
||||||
user_id: User ID
|
count_result = await self.db.execute(select(func.count(Image.id)).where(Image.user_id == user_id))
|
||||||
limit: Maximum number of images to return
|
total = count_result.scalar_one()
|
||||||
offset: Number of images to skip
|
|
||||||
|
|
||||||
Returns:
|
# Get paginated images
|
||||||
Tuple of (images, total_count)
|
|
||||||
"""
|
|
||||||
# Get total count
|
|
||||||
count_result = await self.db.execute(select(Image).where(Image.user_id == user_id))
|
|
||||||
total = len(count_result.scalars().all())
|
|
||||||
|
|
||||||
# Get paginated results
|
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(Image).where(Image.user_id == user_id).order_by(Image.created_at.desc()).limit(limit).offset(offset)
|
select(Image).where(Image.user_id == user_id).order_by(Image.created_at.desc()).limit(limit).offset(offset)
|
||||||
)
|
)
|
||||||
images = result.scalars().all()
|
images = result.scalars().all()
|
||||||
|
|
||||||
return images, total
|
return images, total
|
||||||
|
|
||||||
async def delete_image(self, image_id: UUID) -> bool:
|
async def delete_image(self, image_id: UUID) -> bool:
|
||||||
"""
|
"""Delete image record."""
|
||||||
Delete image record.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_id: Image ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if deleted, False if not found
|
|
||||||
"""
|
|
||||||
image = await self.get_image_by_id(image_id)
|
image = await self.get_image_by_id(image_id)
|
||||||
if not image:
|
if not image:
|
||||||
return False
|
return False
|
||||||
@@ -115,27 +75,14 @@ class ImageRepository:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
async def increment_reference_count(self, image_id: UUID) -> None:
|
async def increment_reference_count(self, image_id: UUID) -> None:
|
||||||
"""
|
"""Increment reference count for image."""
|
||||||
Increment reference count for image.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_id: Image ID
|
|
||||||
"""
|
|
||||||
image = await self.get_image_by_id(image_id)
|
image = await self.get_image_by_id(image_id)
|
||||||
if image:
|
if image:
|
||||||
image.reference_count += 1
|
image.reference_count += 1
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
|
|
||||||
async def decrement_reference_count(self, image_id: UUID) -> int:
|
async def decrement_reference_count(self, image_id: UUID) -> int:
|
||||||
"""
|
"""Decrement reference count for image."""
|
||||||
Decrement reference count for image.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_id: Image ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
New reference count
|
|
||||||
"""
|
|
||||||
image = await self.get_image_by_id(image_id)
|
image = await self.get_image_by_id(image_id)
|
||||||
if image and image.reference_count > 0:
|
if image and image.reference_count > 0:
|
||||||
image.reference_count -= 1
|
image.reference_count -= 1
|
||||||
@@ -151,19 +98,7 @@ class ImageRepository:
|
|||||||
transformations: dict,
|
transformations: dict,
|
||||||
z_order: int = 0,
|
z_order: int = 0,
|
||||||
) -> BoardImage:
|
) -> BoardImage:
|
||||||
"""
|
"""Add image to board."""
|
||||||
Add image to board.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
board_id: Board ID
|
|
||||||
image_id: Image ID
|
|
||||||
position: Canvas position {x, y}
|
|
||||||
transformations: Image transformations
|
|
||||||
z_order: Layer order
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Created BoardImage instance
|
|
||||||
"""
|
|
||||||
board_image = BoardImage(
|
board_image = BoardImage(
|
||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
image_id=image_id,
|
image_id=image_id,
|
||||||
@@ -181,35 +116,50 @@ class ImageRepository:
|
|||||||
return board_image
|
return board_image
|
||||||
|
|
||||||
async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]:
|
async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]:
|
||||||
"""
|
"""Get all images for a board, ordered by z-order."""
|
||||||
Get all images for a board, ordered by z-order.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
board_id: Board ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of BoardImage instances
|
|
||||||
"""
|
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(BoardImage).where(BoardImage.board_id == board_id).order_by(BoardImage.z_order.asc())
|
select(BoardImage).where(BoardImage.board_id == board_id).order_by(BoardImage.z_order.asc())
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
async def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool:
|
async def get_board_image(self, board_id: UUID, image_id: UUID) -> BoardImage | None:
|
||||||
"""
|
"""Get a specific board image."""
|
||||||
Remove image from board.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
board_id: Board ID
|
|
||||||
image_id: Image ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if removed, False if not found
|
|
||||||
"""
|
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
|
select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
|
||||||
)
|
)
|
||||||
board_image = result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def update_board_image(
|
||||||
|
self,
|
||||||
|
board_id: UUID,
|
||||||
|
image_id: UUID,
|
||||||
|
position: dict | None = None,
|
||||||
|
transformations: dict | None = None,
|
||||||
|
z_order: int | None = None,
|
||||||
|
group_id: UUID | None = None,
|
||||||
|
) -> BoardImage | None:
|
||||||
|
"""Update board image position, transformations, z-order, or group."""
|
||||||
|
board_image = await self.get_board_image(board_id, image_id)
|
||||||
|
|
||||||
|
if not board_image:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if position is not None:
|
||||||
|
board_image.position = position
|
||||||
|
if transformations is not None:
|
||||||
|
board_image.transformations = transformations
|
||||||
|
if z_order is not None:
|
||||||
|
board_image.z_order = z_order
|
||||||
|
if group_id is not None:
|
||||||
|
board_image.group_id = group_id
|
||||||
|
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(board_image)
|
||||||
|
return board_image
|
||||||
|
|
||||||
|
async def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool:
|
||||||
|
"""Remove image from board."""
|
||||||
|
board_image = await self.get_board_image(board_id, image_id)
|
||||||
|
|
||||||
if not board_image:
|
if not board_image:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -26,13 +26,14 @@ class ImageUploadResponse(BaseModel):
|
|||||||
mime_type: str
|
mime_type: str
|
||||||
width: int
|
width: int
|
||||||
height: int
|
height: int
|
||||||
metadata: dict[str, Any]
|
metadata: dict[str, Any] = Field(..., alias="image_metadata")
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
"""Pydantic config."""
|
"""Pydantic config."""
|
||||||
|
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
populate_by_name = True
|
||||||
|
|
||||||
|
|
||||||
class ImageResponse(BaseModel):
|
class ImageResponse(BaseModel):
|
||||||
@@ -46,7 +47,7 @@ class ImageResponse(BaseModel):
|
|||||||
mime_type: str
|
mime_type: str
|
||||||
width: int
|
width: int
|
||||||
height: int
|
height: int
|
||||||
metadata: dict[str, Any]
|
metadata: dict[str, Any] = Field(..., alias="image_metadata")
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
reference_count: int
|
reference_count: int
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ class ImageResponse(BaseModel):
|
|||||||
"""Pydantic config."""
|
"""Pydantic config."""
|
||||||
|
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
populate_by_name = True
|
||||||
|
|
||||||
|
|
||||||
class BoardImageCreate(BaseModel):
|
class BoardImageCreate(BaseModel):
|
||||||
|
|||||||
76
flake.nix
76
flake.nix
@@ -31,7 +31,8 @@
|
|||||||
alembic
|
alembic
|
||||||
pydantic
|
pydantic
|
||||||
pydantic-settings # Settings management
|
pydantic-settings # Settings management
|
||||||
psycopg2 # PostgreSQL driver
|
psycopg2 # PostgreSQL driver (sync)
|
||||||
|
asyncpg # PostgreSQL driver (async)
|
||||||
# Auth & Security
|
# Auth & Security
|
||||||
python-jose
|
python-jose
|
||||||
passlib
|
passlib
|
||||||
@@ -88,6 +89,7 @@
|
|||||||
# Development tools
|
# Development tools
|
||||||
git
|
git
|
||||||
direnv
|
direnv
|
||||||
|
tmux
|
||||||
];
|
];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
@@ -105,6 +107,7 @@
|
|||||||
echo " Status: ./scripts/dev-services.sh status"
|
echo " Status: ./scripts/dev-services.sh status"
|
||||||
echo ""
|
echo ""
|
||||||
echo "📚 Quick Commands:"
|
echo "📚 Quick Commands:"
|
||||||
|
echo " Dev (tmux): nix run .#dev"
|
||||||
echo " Backend: cd backend && uvicorn app.main:app --reload"
|
echo " Backend: cd backend && uvicorn app.main:app --reload"
|
||||||
echo " Frontend: cd frontend && npm run dev"
|
echo " Frontend: cd frontend && npm run dev"
|
||||||
echo " Database: psql -h localhost -U webref webref"
|
echo " Database: psql -h localhost -U webref webref"
|
||||||
@@ -131,6 +134,7 @@
|
|||||||
type = "app";
|
type = "app";
|
||||||
program = "${pkgs.writeShellScript "help" ''
|
program = "${pkgs.writeShellScript "help" ''
|
||||||
echo "Available commands:"
|
echo "Available commands:"
|
||||||
|
echo " nix run .#dev - Start backend + frontend in tmux"
|
||||||
echo " nix run .#lint - Run all linting checks"
|
echo " nix run .#lint - Run all linting checks"
|
||||||
echo " nix run .#lint-backend - Run backend linting only"
|
echo " nix run .#lint-backend - Run backend linting only"
|
||||||
echo " nix run .#lint-frontend - Run frontend linting only"
|
echo " nix run .#lint-frontend - Run frontend linting only"
|
||||||
@@ -138,6 +142,76 @@
|
|||||||
''}";
|
''}";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Development runner with tmux
|
||||||
|
dev = {
|
||||||
|
type = "app";
|
||||||
|
program = "${pkgs.writeShellScript "dev-tmux" ''
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Check if we're in the project root
|
||||||
|
if [ ! -d "backend" ] || [ ! -d "frontend" ]; then
|
||||||
|
echo "❌ Error: Not in project root directory"
|
||||||
|
echo "Please run this command from the webref project root"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if frontend dependencies are installed
|
||||||
|
if [ ! -d "frontend/node_modules" ]; then
|
||||||
|
echo "📦 Installing frontend dependencies..."
|
||||||
|
cd frontend
|
||||||
|
${pkgs.nodejs}/bin/npm install
|
||||||
|
cd ..
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
export DATABASE_URL="postgresql://webref@localhost:5432/webref"
|
||||||
|
export MINIO_ENDPOINT="localhost:9000"
|
||||||
|
export MINIO_ACCESS_KEY="minioadmin"
|
||||||
|
export MINIO_SECRET_KEY="minioadmin"
|
||||||
|
export PYTHONPATH="$PWD/backend:$PYTHONPATH"
|
||||||
|
export PATH="${pythonEnv}/bin:${pkgs.nodejs}/bin:$PATH"
|
||||||
|
|
||||||
|
# Session name
|
||||||
|
SESSION_NAME="webref-dev"
|
||||||
|
|
||||||
|
# Kill existing session if it exists
|
||||||
|
${pkgs.tmux}/bin/tmux has-session -t $SESSION_NAME 2>/dev/null && ${pkgs.tmux}/bin/tmux kill-session -t $SESSION_NAME
|
||||||
|
|
||||||
|
echo "🚀 Starting development environment in tmux..."
|
||||||
|
echo ""
|
||||||
|
echo "📋 Tmux Controls:"
|
||||||
|
echo " Switch panes: Ctrl+b → arrow keys"
|
||||||
|
echo " Scroll mode: Ctrl+b → ["
|
||||||
|
echo " Exit scroll: q"
|
||||||
|
echo " Detach session: Ctrl+b → d"
|
||||||
|
echo " Kill session: Ctrl+b → :kill-session"
|
||||||
|
echo ""
|
||||||
|
echo "Starting in 2 seconds..."
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Create new tmux session with backend
|
||||||
|
${pkgs.tmux}/bin/tmux new-session -d -s "$SESSION_NAME" -n "webref" -c "$PWD/backend" \
|
||||||
|
"printf '\n🐍 Starting Backend (uvicorn)...\n\n' && ${pythonEnv}/bin/uvicorn app.main:app --reload --host 0.0.0.0 --port 8000; read -p 'Backend stopped. Press Enter to exit...'"
|
||||||
|
|
||||||
|
# Split window vertically and run frontend
|
||||||
|
${pkgs.tmux}/bin/tmux split-window -h -t "$SESSION_NAME":0 -c "$PWD/frontend" \
|
||||||
|
"printf '\n⚡ Starting Frontend (Vite)...\n\n' && ${pkgs.nodejs}/bin/npm run dev; read -p 'Frontend stopped. Press Enter to exit...'"
|
||||||
|
|
||||||
|
# Set pane titles
|
||||||
|
${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.0 -T "Backend (uvicorn)"
|
||||||
|
${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.1 -T "Frontend (vite)"
|
||||||
|
|
||||||
|
# Balance panes
|
||||||
|
${pkgs.tmux}/bin/tmux select-layout -t "$SESSION_NAME":0 even-horizontal
|
||||||
|
|
||||||
|
# Focus on backend pane
|
||||||
|
${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.0
|
||||||
|
|
||||||
|
# Attach to session
|
||||||
|
${pkgs.tmux}/bin/tmux attach-session -t "$SESSION_NAME"
|
||||||
|
''}";
|
||||||
|
};
|
||||||
|
|
||||||
# Unified linting - calls both backend and frontend lints
|
# Unified linting - calls both backend and frontend lints
|
||||||
lint = {
|
lint = {
|
||||||
type = "app";
|
type = "app";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
57
frontend/src/routes/+page.svelte
Normal file
57
frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Client-side redirect based on auth token
|
||||||
|
if (browser) {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (token) {
|
||||||
|
goto('/boards');
|
||||||
|
} else {
|
||||||
|
goto('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Reference Board Viewer</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="loading-container">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid #e5e7eb;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
2
frontend/src/routes/+page.ts
Normal file
2
frontend/src/routes/+page.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Disable server-side rendering for the root page
|
||||||
|
export const ssr = false;
|
||||||
684
frontend/src/routes/boards/[id]/+page.svelte
Normal file
684
frontend/src/routes/boards/[id]/+page.svelte
Normal file
@@ -0,0 +1,684 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { boards, currentBoard } from '$lib/stores/boards';
|
||||||
|
import {
|
||||||
|
boardImages,
|
||||||
|
loadBoardImages,
|
||||||
|
uploadSingleImage,
|
||||||
|
uploadZipFile,
|
||||||
|
addImageToBoard,
|
||||||
|
} from '$lib/stores/images';
|
||||||
|
import { viewport } from '$lib/stores/viewport';
|
||||||
|
import Stage from '$lib/canvas/Stage.svelte';
|
||||||
|
import CanvasImage from '$lib/canvas/Image.svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import * as imagesApi from '$lib/api/images';
|
||||||
|
|
||||||
|
let loading = true;
|
||||||
|
let error = '';
|
||||||
|
let uploadError = '';
|
||||||
|
let uploadSuccess = '';
|
||||||
|
let uploading = false;
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
|
let canvasContainer: HTMLDivElement;
|
||||||
|
let canvasWidth = 0;
|
||||||
|
let canvasHeight = 0;
|
||||||
|
let stageComponent: Stage;
|
||||||
|
let stageReady = false;
|
||||||
|
let loadedImagesCount = 0;
|
||||||
|
|
||||||
|
$: boardId = $page.params.id;
|
||||||
|
$: canvasLayer = stageReady && stageComponent ? stageComponent.getLayer() : null;
|
||||||
|
|
||||||
|
// Track loaded images and force redraw
|
||||||
|
$: if (loadedImagesCount > 0 && stageComponent) {
|
||||||
|
const layer = stageComponent.getLayer();
|
||||||
|
if (layer) {
|
||||||
|
setTimeout(() => layer.batchDraw(), 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
await boards.loadBoard(boardId);
|
||||||
|
await loadBoardImages(boardId);
|
||||||
|
|
||||||
|
// Load viewport state from board if available
|
||||||
|
if ($currentBoard?.viewport_state) {
|
||||||
|
viewport.loadState($currentBoard.viewport_state);
|
||||||
|
} else {
|
||||||
|
// Reset to default if no saved state
|
||||||
|
viewport.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set canvas dimensions BEFORE creating stage
|
||||||
|
updateCanvasDimensions();
|
||||||
|
|
||||||
|
// Wait for dimensions to be set
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
// Double-check dimensions are valid
|
||||||
|
if (canvasWidth === 0 || canvasHeight === 0) {
|
||||||
|
console.warn('Canvas dimensions are 0, forcing update...');
|
||||||
|
updateCanvasDimensions();
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', updateCanvasDimensions);
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error = (err as { error?: string })?.error || 'Failed to load board';
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', updateCanvasDimensions);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateCanvasDimensions() {
|
||||||
|
if (canvasContainer) {
|
||||||
|
canvasWidth = canvasContainer.clientWidth;
|
||||||
|
canvasHeight = canvasContainer.clientHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStageReady() {
|
||||||
|
// Wait for next tick to ensure layer is fully ready
|
||||||
|
await tick();
|
||||||
|
stageReady = true;
|
||||||
|
loadedImagesCount = 0; // Reset counter
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageLoaded(_imageId: string) {
|
||||||
|
loadedImagesCount++;
|
||||||
|
|
||||||
|
// Force immediate redraw on each image load
|
||||||
|
if (stageComponent) {
|
||||||
|
const layer = stageComponent.getLayer();
|
||||||
|
const stage = stageComponent.getStage();
|
||||||
|
if (layer && stage) {
|
||||||
|
layer.batchDraw();
|
||||||
|
stage.batchDraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When all images loaded, auto-fit to view on first load
|
||||||
|
if (loadedImagesCount === $boardImages.length) {
|
||||||
|
const layer = stageComponent?.getLayer();
|
||||||
|
const stage = stageComponent?.getStage();
|
||||||
|
|
||||||
|
if (layer && stage) {
|
||||||
|
// Multiple redraws to ensure visibility
|
||||||
|
setTimeout(() => {
|
||||||
|
layer.batchDraw();
|
||||||
|
stage.batchDraw();
|
||||||
|
}, 0);
|
||||||
|
setTimeout(() => {
|
||||||
|
layer.batchDraw();
|
||||||
|
stage.batchDraw();
|
||||||
|
}, 100);
|
||||||
|
setTimeout(() => {
|
||||||
|
layer.batchDraw();
|
||||||
|
stage.batchDraw();
|
||||||
|
|
||||||
|
// Auto-fit images on first load (if they're off-screen)
|
||||||
|
const hasOffscreenImages = $boardImages.some(
|
||||||
|
(bi) =>
|
||||||
|
bi.position.x < -canvasWidth ||
|
||||||
|
bi.position.y < -canvasHeight ||
|
||||||
|
bi.position.x > canvasWidth * 2 ||
|
||||||
|
bi.position.y > canvasHeight * 2
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasOffscreenImages) {
|
||||||
|
fitAllImages();
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImageDragEnd(imageId: string, x: number, y: number) {
|
||||||
|
// Update position on backend
|
||||||
|
try {
|
||||||
|
const boardImage = $boardImages.find((bi) => bi.id === imageId);
|
||||||
|
if (!boardImage) return;
|
||||||
|
|
||||||
|
await imagesApi.updateBoardImage(boardId, boardImage.image_id, {
|
||||||
|
position: { x, y },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update local store
|
||||||
|
boardImages.update((images) =>
|
||||||
|
images.map((img) => (img.id === imageId ? { ...img, position: { x, y } } : img))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update image position:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageSelectionChange(_imageId: string, _isSelected: boolean) {
|
||||||
|
// Selection handling
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileSelect(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
if (!target.files || target.files.length === 0) return;
|
||||||
|
|
||||||
|
await processFiles(Array.from(target.files));
|
||||||
|
|
||||||
|
// Reset input
|
||||||
|
if (target) target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (!event.dataTransfer?.files) return;
|
||||||
|
|
||||||
|
await processFiles(Array.from(event.dataTransfer.files));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processFiles(files: File[]) {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
uploading = true;
|
||||||
|
uploadError = '';
|
||||||
|
uploadSuccess = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let totalUploaded = 0;
|
||||||
|
|
||||||
|
// Calculate starting position (centered on screen with some spacing)
|
||||||
|
let currentX = canvasWidth / 2 - 200;
|
||||||
|
let currentY = canvasHeight / 2 - 200;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// Upload to library first
|
||||||
|
if (file.name.toLowerCase().endsWith('.zip')) {
|
||||||
|
const images = await uploadZipFile(file);
|
||||||
|
// Add each image to board with spaced positions
|
||||||
|
for (const img of images) {
|
||||||
|
await addImageToBoard(boardId, img.id, { x: currentX, y: currentY }, totalUploaded);
|
||||||
|
// Offset next image
|
||||||
|
currentX += 50;
|
||||||
|
currentY += 50;
|
||||||
|
}
|
||||||
|
totalUploaded += images.length;
|
||||||
|
} else if (file.type.startsWith('image/')) {
|
||||||
|
const image = await uploadSingleImage(file);
|
||||||
|
// Add to board at calculated position
|
||||||
|
await addImageToBoard(boardId, image.id, { x: currentX, y: currentY }, totalUploaded);
|
||||||
|
// Offset next image
|
||||||
|
currentX += 50;
|
||||||
|
currentY += 50;
|
||||||
|
totalUploaded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload board images to show new uploads
|
||||||
|
await loadBoardImages(boardId);
|
||||||
|
|
||||||
|
uploadSuccess = `Successfully uploaded ${totalUploaded} image(s)`;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
uploadSuccess = '';
|
||||||
|
}, 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Upload error:', err);
|
||||||
|
uploadError = err.error || err.message || err.detail || 'Upload failed';
|
||||||
|
} finally {
|
||||||
|
uploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFilePicker() {
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditBoard() {
|
||||||
|
goto(`/boards/${boardId}/edit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackToBoards() {
|
||||||
|
goto('/boards');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitAllImages() {
|
||||||
|
if ($boardImages.length === 0) return;
|
||||||
|
|
||||||
|
// Calculate bounding box of all images
|
||||||
|
let minX = Infinity,
|
||||||
|
minY = Infinity,
|
||||||
|
maxX = -Infinity,
|
||||||
|
maxY = -Infinity;
|
||||||
|
|
||||||
|
$boardImages.forEach((bi) => {
|
||||||
|
const imgMinX = bi.position.x;
|
||||||
|
const imgMinY = bi.position.y;
|
||||||
|
const imgMaxX = bi.position.x + (bi.image?.width || 0);
|
||||||
|
const imgMaxY = bi.position.y + (bi.image?.height || 0);
|
||||||
|
|
||||||
|
minX = Math.min(minX, imgMinX);
|
||||||
|
minY = Math.min(minY, imgMinY);
|
||||||
|
maxX = Math.max(maxX, imgMaxX);
|
||||||
|
maxY = Math.max(maxY, imgMaxY);
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentWidth = maxX - minX;
|
||||||
|
const contentHeight = maxY - minY;
|
||||||
|
|
||||||
|
// Calculate zoom to fit
|
||||||
|
const padding = 100;
|
||||||
|
const scaleX = (canvasWidth - padding * 2) / contentWidth;
|
||||||
|
const scaleY = (canvasHeight - padding * 2) / contentHeight;
|
||||||
|
const newZoom = Math.min(scaleX, scaleY, 1.0); // Don't zoom in more than 100%
|
||||||
|
|
||||||
|
// Calculate center position
|
||||||
|
const centerX = (canvasWidth - contentWidth * newZoom) / 2 - minX * newZoom;
|
||||||
|
const centerY = (canvasHeight - contentHeight * newZoom) / 2 - minY * newZoom;
|
||||||
|
|
||||||
|
viewport.set({
|
||||||
|
x: centerX,
|
||||||
|
y: centerY,
|
||||||
|
zoom: newZoom,
|
||||||
|
rotation: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$currentBoard?.title || 'Board'} - Reference Board Viewer</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="board-canvas-page"
|
||||||
|
on:drop={handleDrop}
|
||||||
|
on:dragover={handleDragOver}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Loading board...</p>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="error-message">
|
||||||
|
<p>{error}</p>
|
||||||
|
<button on:click={handleBackToBoards}>Back to Boards</button>
|
||||||
|
</div>
|
||||||
|
{:else if $currentBoard}
|
||||||
|
<!-- Top Toolbar -->
|
||||||
|
<div class="toolbar-top">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<button class="btn-icon" on:click={handleBackToBoards} title="Back to boards">
|
||||||
|
← Boards
|
||||||
|
</button>
|
||||||
|
<div class="title-group">
|
||||||
|
<h1 class="board-title">{$currentBoard.title}</h1>
|
||||||
|
{#if $currentBoard.description}
|
||||||
|
<span class="board-desc">{$currentBoard.description}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<button
|
||||||
|
class="btn-icon"
|
||||||
|
on:click={fitAllImages}
|
||||||
|
title="Fit all images to view"
|
||||||
|
disabled={$boardImages.length === 0}
|
||||||
|
>
|
||||||
|
🔍 Fit All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-upload"
|
||||||
|
on:click={openFilePicker}
|
||||||
|
disabled={uploading}
|
||||||
|
title="Upload images"
|
||||||
|
>
|
||||||
|
{#if uploading}
|
||||||
|
<span class="spinner-small"></span>
|
||||||
|
Uploading...
|
||||||
|
{:else}
|
||||||
|
📤 Upload Images
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary" on:click={handleEditBoard} title="Edit board settings">
|
||||||
|
⚙️ Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden file input -->
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*,.zip"
|
||||||
|
multiple
|
||||||
|
on:change={handleFileSelect}
|
||||||
|
style="display: none;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if uploadSuccess}
|
||||||
|
<div class="success-banner">✓ {uploadSuccess}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if uploadError}
|
||||||
|
<div class="error-banner">⚠ {uploadError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Canvas Area -->
|
||||||
|
<div class="canvas-container" bind:this={canvasContainer}>
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Loading canvas...</p>
|
||||||
|
</div>
|
||||||
|
{:else if $boardImages.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">🖼️</div>
|
||||||
|
<h2>No images yet</h2>
|
||||||
|
<p>Click "Upload Images" or drag & drop files anywhere to get started</p>
|
||||||
|
<button class="btn-upload-large" on:click={openFilePicker}>
|
||||||
|
Upload Your First Image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if canvasWidth > 0 && canvasHeight > 0}
|
||||||
|
<Stage
|
||||||
|
bind:this={stageComponent}
|
||||||
|
width={canvasWidth}
|
||||||
|
height={canvasHeight}
|
||||||
|
{boardId}
|
||||||
|
on:ready={handleStageReady}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if stageReady && canvasLayer}
|
||||||
|
{#each $boardImages as boardImage (boardImage.id)}
|
||||||
|
{#if boardImage.image}
|
||||||
|
<CanvasImage
|
||||||
|
id={boardImage.id}
|
||||||
|
imageId={boardImage.image.id}
|
||||||
|
imageUrl="http://localhost:8000/api/v1/images/{boardImage.image
|
||||||
|
.id}/serve?quality=medium"
|
||||||
|
x={boardImage.position.x}
|
||||||
|
y={boardImage.position.y}
|
||||||
|
width={boardImage.image.width}
|
||||||
|
height={boardImage.image.height}
|
||||||
|
rotation={boardImage.transformations.rotation || 0}
|
||||||
|
scaleX={boardImage.transformations.scale || 1.0}
|
||||||
|
scaleY={boardImage.transformations.scale || 1.0}
|
||||||
|
opacity={boardImage.transformations.opacity || 1.0}
|
||||||
|
zOrder={boardImage.z_order}
|
||||||
|
layer={canvasLayer}
|
||||||
|
onDragEnd={handleImageDragEnd}
|
||||||
|
onSelectionChange={handleImageSelectionChange}
|
||||||
|
onImageLoaded={handleImageLoaded}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<div class="status-bar">
|
||||||
|
<span>Board: {$currentBoard.title}</span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>Images: {$boardImages.length}</span>
|
||||||
|
<span>|</span>
|
||||||
|
<span class="status-ready">● Ready</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="error-message">
|
||||||
|
<p>Board not found</p>
|
||||||
|
<button on:click={handleBackToBoards}>Back to Boards</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.board-canvas-page {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid #e5e7eb;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.toolbar-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-desc {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon,
|
||||||
|
.btn-upload,
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload:hover:not(:disabled) {
|
||||||
|
background: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload-large {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload-large:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #6b7280;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-banner,
|
||||||
|
.error-banner {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-banner {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
border-bottom: 1px solid #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
border-bottom: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Canvas Container */
|
||||||
|
.canvas-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
background: white;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Bar */
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
background: white;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ready {
|
||||||
|
color: #10b981;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
frontend/vite.config.ts
Normal file
11
frontend/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user