Compare commits
2 Commits
948fe591dc
...
d4fbdf9273
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4fbdf9273 | ||
|
|
c68a6a7d01 |
128
backend/app/api/export.py
Normal file
128
backend/app/api/export.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Export API endpoints for downloading and exporting images."""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user, get_db
|
||||||
|
from app.database.models.board import Board
|
||||||
|
from app.database.models.board_image import BoardImage
|
||||||
|
from app.database.models.image import Image
|
||||||
|
from app.database.models.user import User
|
||||||
|
from app.images.download import download_single_image
|
||||||
|
from app.images.export_composite import create_composite_export
|
||||||
|
from app.images.export_zip import create_zip_export
|
||||||
|
|
||||||
|
router = APIRouter(tags=["export"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/images/{image_id}/download")
|
||||||
|
async def download_image(
|
||||||
|
image_id: UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""
|
||||||
|
Download a single image.
|
||||||
|
|
||||||
|
Only the image owner can download it.
|
||||||
|
"""
|
||||||
|
# Verify image exists and user owns it
|
||||||
|
image = db.query(Image).filter(Image.id == image_id, Image.user_id == current_user.id).first()
|
||||||
|
|
||||||
|
if image is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Image not found or access denied",
|
||||||
|
)
|
||||||
|
|
||||||
|
return await download_single_image(image.storage_path, image.filename)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/boards/{board_id}/export/zip")
|
||||||
|
def export_board_zip(
|
||||||
|
board_id: UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""
|
||||||
|
Export all images from a board as a ZIP file.
|
||||||
|
|
||||||
|
Only the board owner can export it.
|
||||||
|
"""
|
||||||
|
# Verify board exists and user owns it
|
||||||
|
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
|
||||||
|
|
||||||
|
if board is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Board not found or access denied",
|
||||||
|
)
|
||||||
|
|
||||||
|
return create_zip_export(str(board_id), db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/boards/{board_id}/export/composite")
|
||||||
|
def export_board_composite(
|
||||||
|
board_id: UUID,
|
||||||
|
scale: float = Query(1.0, ge=0.5, le=4.0, description="Resolution scale (0.5x to 4x)"),
|
||||||
|
format: str = Query("PNG", regex="^(PNG|JPEG)$", description="Output format (PNG or JPEG)"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""
|
||||||
|
Export board as a single composite image showing the layout.
|
||||||
|
|
||||||
|
Only the board owner can export it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scale: Resolution multiplier (0.5x, 1x, 2x, 4x)
|
||||||
|
format: Output format (PNG or JPEG)
|
||||||
|
"""
|
||||||
|
# Verify board exists and user owns it
|
||||||
|
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
|
||||||
|
|
||||||
|
if board is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Board not found or access denied",
|
||||||
|
)
|
||||||
|
|
||||||
|
return create_composite_export(str(board_id), db, scale=scale, format=format)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/boards/{board_id}/export/info")
|
||||||
|
def get_export_info(
|
||||||
|
board_id: UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Get information about board export (image count, estimated size).
|
||||||
|
|
||||||
|
Useful for showing progress estimates.
|
||||||
|
"""
|
||||||
|
# Verify board exists and user owns it
|
||||||
|
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
|
||||||
|
|
||||||
|
if board is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Board not found or access denied",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count images and calculate estimated size
|
||||||
|
images = (
|
||||||
|
db.query(Image).join(BoardImage, BoardImage.image_id == Image.id).filter(BoardImage.board_id == board_id).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
total_size = sum(img.file_size for img in images)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"board_id": str(board_id),
|
||||||
|
"image_count": len(images),
|
||||||
|
"total_size_bytes": total_size,
|
||||||
|
"estimated_zip_size_bytes": int(total_size * 0.95), # ZIP usually has small overhead
|
||||||
|
}
|
||||||
277
backend/app/api/sharing.py
Normal file
277
backend/app/api/sharing.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"""Board sharing API endpoints."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.boards.schemas import (
|
||||||
|
BoardDetail,
|
||||||
|
CommentCreate,
|
||||||
|
CommentResponse,
|
||||||
|
ShareLinkCreate,
|
||||||
|
ShareLinkResponse,
|
||||||
|
)
|
||||||
|
from app.boards.sharing import generate_secure_token
|
||||||
|
from app.core.deps import get_current_user, get_db
|
||||||
|
from app.database.models.board import Board
|
||||||
|
from app.database.models.comment import Comment
|
||||||
|
from app.database.models.share_link import ShareLink
|
||||||
|
from app.database.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(tags=["sharing"])
|
||||||
|
|
||||||
|
|
||||||
|
def validate_share_link(token: str, db: Session, required_permission: str = "view-only") -> ShareLink:
|
||||||
|
"""
|
||||||
|
Validate share link token and check permissions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: Share link token
|
||||||
|
db: Database session
|
||||||
|
required_permission: Required permission level
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ShareLink if valid
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 403 if invalid or insufficient permissions
|
||||||
|
"""
|
||||||
|
share_link = (
|
||||||
|
db.query(ShareLink)
|
||||||
|
.filter(
|
||||||
|
ShareLink.token == token,
|
||||||
|
ShareLink.is_revoked == False, # noqa: E712
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if share_link is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Invalid or revoked share link",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check expiration
|
||||||
|
if share_link.expires_at and share_link.expires_at < datetime.utcnow():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Share link has expired",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check permission level
|
||||||
|
if required_permission == "view-comment" and share_link.permission_level != "view-comment":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Insufficient permissions - commenting not allowed",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update access tracking
|
||||||
|
share_link.access_count += 1
|
||||||
|
share_link.last_accessed_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return share_link
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/boards/{board_id}/share-links", response_model=ShareLinkResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_share_link(
|
||||||
|
board_id: UUID,
|
||||||
|
share_link_data: ShareLinkCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> ShareLinkResponse:
|
||||||
|
"""
|
||||||
|
Create a new share link for a board.
|
||||||
|
|
||||||
|
Only the board owner can create share links.
|
||||||
|
"""
|
||||||
|
# Verify board exists and user owns it
|
||||||
|
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
|
||||||
|
|
||||||
|
if board is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Board not found or access denied",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate unique token
|
||||||
|
token = generate_secure_token()
|
||||||
|
|
||||||
|
# Create share link
|
||||||
|
share_link = ShareLink(
|
||||||
|
board_id=board_id,
|
||||||
|
token=token,
|
||||||
|
permission_level=share_link_data.permission_level,
|
||||||
|
expires_at=share_link_data.expires_at,
|
||||||
|
)
|
||||||
|
db.add(share_link)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(share_link)
|
||||||
|
|
||||||
|
return ShareLinkResponse.model_validate(share_link)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/boards/{board_id}/share-links", response_model=list[ShareLinkResponse])
|
||||||
|
def list_share_links(
|
||||||
|
board_id: UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> list[ShareLinkResponse]:
|
||||||
|
"""
|
||||||
|
List all share links for a board.
|
||||||
|
|
||||||
|
Only the board owner can list share links.
|
||||||
|
"""
|
||||||
|
# Verify board exists and user owns it
|
||||||
|
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
|
||||||
|
|
||||||
|
if board is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Board not found or access denied",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get all share links for board
|
||||||
|
share_links = db.query(ShareLink).filter(ShareLink.board_id == board_id).order_by(ShareLink.created_at.desc()).all()
|
||||||
|
|
||||||
|
return [ShareLinkResponse.model_validate(link) for link in share_links]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/boards/{board_id}/share-links/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def revoke_share_link(
|
||||||
|
board_id: UUID,
|
||||||
|
link_id: UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Revoke (soft delete) a share link.
|
||||||
|
|
||||||
|
Only the board owner can revoke share links.
|
||||||
|
"""
|
||||||
|
# Verify board exists and user owns it
|
||||||
|
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
|
||||||
|
|
||||||
|
if board is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Board not found or access denied",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get and revoke share link
|
||||||
|
share_link = db.query(ShareLink).filter(ShareLink.id == link_id, ShareLink.board_id == board_id).first()
|
||||||
|
|
||||||
|
if share_link is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Share link not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
share_link.is_revoked = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shared/{token}", response_model=BoardDetail)
|
||||||
|
def get_shared_board(
|
||||||
|
token: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> BoardDetail:
|
||||||
|
"""
|
||||||
|
Access a shared board via token.
|
||||||
|
|
||||||
|
No authentication required - access controlled by share link token.
|
||||||
|
"""
|
||||||
|
# Validate share link
|
||||||
|
share_link = validate_share_link(token, db, required_permission="view-only")
|
||||||
|
|
||||||
|
# Get board details
|
||||||
|
board = db.query(Board).filter(Board.id == share_link.board_id).first()
|
||||||
|
|
||||||
|
if board is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Board not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
return BoardDetail.model_validate(board)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/shared/{token}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_comment(
|
||||||
|
token: str,
|
||||||
|
comment_data: CommentCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> CommentResponse:
|
||||||
|
"""
|
||||||
|
Create a comment on a shared board.
|
||||||
|
|
||||||
|
Requires view-comment permission level.
|
||||||
|
"""
|
||||||
|
# Validate share link with comment permission
|
||||||
|
share_link = validate_share_link(token, db, required_permission="view-comment")
|
||||||
|
|
||||||
|
# Create comment
|
||||||
|
comment = Comment(
|
||||||
|
board_id=share_link.board_id,
|
||||||
|
share_link_id=share_link.id,
|
||||||
|
author_name=comment_data.author_name,
|
||||||
|
content=comment_data.content,
|
||||||
|
position=comment_data.position,
|
||||||
|
)
|
||||||
|
db.add(comment)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(comment)
|
||||||
|
|
||||||
|
return CommentResponse.model_validate(comment)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shared/{token}/comments", response_model=list[CommentResponse])
|
||||||
|
def list_comments(
|
||||||
|
token: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> list[CommentResponse]:
|
||||||
|
"""
|
||||||
|
List all comments on a shared board.
|
||||||
|
|
||||||
|
Requires view-only or view-comment permission.
|
||||||
|
"""
|
||||||
|
# Validate share link
|
||||||
|
share_link = validate_share_link(token, db, required_permission="view-only")
|
||||||
|
|
||||||
|
# Get all comments for board (non-deleted)
|
||||||
|
comments = (
|
||||||
|
db.query(Comment)
|
||||||
|
.filter(Comment.board_id == share_link.board_id, Comment.is_deleted == False) # noqa: E712
|
||||||
|
.order_by(Comment.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [CommentResponse.model_validate(comment) for comment in comments]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/boards/{board_id}/comments", response_model=list[CommentResponse])
|
||||||
|
def list_board_comments(
|
||||||
|
board_id: UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> list[CommentResponse]:
|
||||||
|
"""
|
||||||
|
List all comments on a board (owner view).
|
||||||
|
|
||||||
|
Only the board owner can access this endpoint.
|
||||||
|
"""
|
||||||
|
# Verify board exists and user owns it
|
||||||
|
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
|
||||||
|
|
||||||
|
if board is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Board not found or access denied",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get all comments for board (including deleted for owner)
|
||||||
|
comments = db.query(Comment).filter(Comment.board_id == board_id).order_by(Comment.created_at.desc()).all()
|
||||||
|
|
||||||
|
return [CommentResponse.model_validate(comment) for comment in comments]
|
||||||
@@ -106,3 +106,49 @@ class GroupResponse(BaseModel):
|
|||||||
member_count: int = Field(default=0, description="Number of images in group")
|
member_count: int = Field(default=0, description="Number of images in group")
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ShareLinkCreate(BaseModel):
|
||||||
|
"""Schema for creating a new share link."""
|
||||||
|
|
||||||
|
permission_level: str = Field(..., pattern=r"^(view-only|view-comment)$", description="Permission level")
|
||||||
|
expires_at: datetime | None = Field(None, description="Optional expiration datetime")
|
||||||
|
|
||||||
|
|
||||||
|
class ShareLinkResponse(BaseModel):
|
||||||
|
"""Response schema for share link."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
board_id: UUID
|
||||||
|
token: str
|
||||||
|
permission_level: str
|
||||||
|
created_at: datetime
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
last_accessed_at: datetime | None = None
|
||||||
|
access_count: int = 0
|
||||||
|
is_revoked: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CommentCreate(BaseModel):
|
||||||
|
"""Schema for creating a new comment."""
|
||||||
|
|
||||||
|
author_name: str = Field(..., min_length=1, max_length=100, description="Commenter name")
|
||||||
|
content: str = Field(..., min_length=1, max_length=5000, description="Comment text")
|
||||||
|
position: dict | None = Field(None, description="Optional canvas position {x, y}")
|
||||||
|
|
||||||
|
|
||||||
|
class CommentResponse(BaseModel):
|
||||||
|
"""Response schema for comment."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
board_id: UUID
|
||||||
|
share_link_id: UUID | None = None
|
||||||
|
author_name: str
|
||||||
|
content: str
|
||||||
|
position: dict | None = None
|
||||||
|
created_at: datetime
|
||||||
|
is_deleted: bool = False
|
||||||
|
|||||||
84
backend/app/boards/sharing.py
Normal file
84
backend/app/boards/sharing.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""Board sharing functionality."""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database.models.share_link import ShareLink
|
||||||
|
|
||||||
|
|
||||||
|
def generate_secure_token(length: int = 64) -> str:
|
||||||
|
"""
|
||||||
|
Generate a cryptographically secure random token for share links.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
length: Length of the token (default 64 characters)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
URL-safe random string
|
||||||
|
"""
|
||||||
|
# Use URL-safe characters (alphanumeric + - and _)
|
||||||
|
alphabet = string.ascii_letters + string.digits + "-_"
|
||||||
|
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_share_link_token(token: str, db: Session) -> ShareLink | None:
|
||||||
|
"""
|
||||||
|
Validate a share link token and return the share link if valid.
|
||||||
|
|
||||||
|
A share link is valid if:
|
||||||
|
- Token exists
|
||||||
|
- Not revoked
|
||||||
|
- Not expired (if expires_at is set)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The share link token
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ShareLink if valid, None otherwise
|
||||||
|
"""
|
||||||
|
share_link = (
|
||||||
|
db.query(ShareLink)
|
||||||
|
.filter(
|
||||||
|
ShareLink.token == token,
|
||||||
|
ShareLink.is_revoked == False, # noqa: E712
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if share_link is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check expiration
|
||||||
|
if share_link.expires_at and share_link.expires_at < datetime.utcnow():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Update access tracking
|
||||||
|
share_link.access_count += 1
|
||||||
|
share_link.last_accessed_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return share_link
|
||||||
|
|
||||||
|
|
||||||
|
def check_permission(share_link: ShareLink, required_permission: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a share link has the required permission level.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
share_link: The share link to check
|
||||||
|
required_permission: Required permission level ('view-only' or 'view-comment')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if permission granted, False otherwise
|
||||||
|
"""
|
||||||
|
if required_permission == "view-only":
|
||||||
|
# Both view-only and view-comment can view
|
||||||
|
return share_link.permission_level in ("view-only", "view-comment")
|
||||||
|
elif required_permission == "view-comment":
|
||||||
|
# Only view-comment can comment
|
||||||
|
return share_link.permission_level == "view-comment"
|
||||||
|
return False
|
||||||
@@ -91,6 +91,27 @@ class StorageClient:
|
|||||||
logger.error(f"Failed to download file {object_name}: {e}")
|
logger.error(f"Failed to download file {object_name}: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def get_object(self, object_name: str) -> bytes | None:
|
||||||
|
"""Get object as bytes from MinIO.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
object_name: S3 object name (path)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bytes: File data or None if not found
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If download fails for reasons other than not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
file_data = self.download_file(object_name)
|
||||||
|
return file_data.read()
|
||||||
|
except ClientError as e:
|
||||||
|
if e.response["Error"]["Code"] == "404":
|
||||||
|
return None
|
||||||
|
logger.error(f"Failed to get object {object_name}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
def delete_file(self, object_name: str) -> None:
|
def delete_file(self, object_name: str) -> None:
|
||||||
"""Delete file from MinIO.
|
"""Delete file from MinIO.
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.database.base import Base
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.database.models.board_image import BoardImage
|
from app.database.models.board_image import BoardImage
|
||||||
|
from app.database.models.comment import Comment
|
||||||
from app.database.models.group import Group
|
from app.database.models.group import Group
|
||||||
from app.database.models.share_link import ShareLink
|
from app.database.models.share_link import ShareLink
|
||||||
from app.database.models.user import User
|
from app.database.models.user import User
|
||||||
@@ -56,6 +57,7 @@ class Board(Base):
|
|||||||
share_links: Mapped[list["ShareLink"]] = relationship(
|
share_links: Mapped[list["ShareLink"]] = relationship(
|
||||||
"ShareLink", back_populates="board", cascade="all, delete-orphan"
|
"ShareLink", back_populates="board", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
comments: Mapped[list["Comment"]] = relationship("Comment", back_populates="board", cascade="all, delete-orphan")
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""String representation of Board."""
|
"""String representation of Board."""
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Comment model for board comments."""
|
"""Comment model for board annotations."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -11,18 +11,16 @@ from app.database.base import Base
|
|||||||
|
|
||||||
|
|
||||||
class Comment(Base):
|
class Comment(Base):
|
||||||
"""Comment model for viewer comments on shared boards."""
|
"""Comment model representing viewer comments on shared boards."""
|
||||||
|
|
||||||
__tablename__ = "comments"
|
__tablename__ = "comments"
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True)
|
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False)
|
||||||
share_link_id = Column(
|
share_link_id = Column(UUID(as_uuid=True), ForeignKey("share_links.id", ondelete="SET NULL"), nullable=True)
|
||||||
UUID(as_uuid=True), ForeignKey("share_links.id", ondelete="SET NULL"), nullable=True, index=True
|
|
||||||
)
|
|
||||||
author_name = Column(String(100), nullable=False)
|
author_name = Column(String(100), nullable=False)
|
||||||
content = Column(Text, nullable=False)
|
content = Column(Text, nullable=False)
|
||||||
position = Column(JSONB, nullable=True) # Optional canvas position
|
position = Column(JSONB, nullable=True) # Optional canvas position reference
|
||||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
is_deleted = Column(Boolean, nullable=False, default=False)
|
is_deleted = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
@@ -31,4 +29,4 @@ class Comment(Base):
|
|||||||
share_link = relationship("ShareLink", back_populates="comments")
|
share_link = relationship("ShareLink", back_populates="comments")
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<Comment(id={self.id}, author={self.author_name})>"
|
return f"<Comment(id={self.id}, board_id={self.board_id}, author={self.author_name})>"
|
||||||
|
|||||||
@@ -1,45 +1,33 @@
|
|||||||
"""ShareLink database model."""
|
"""ShareLink model for board sharing functionality."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
|
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from app.database.base import Base
|
from app.database.base import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.database.models.board import Board
|
|
||||||
|
|
||||||
|
|
||||||
class ShareLink(Base):
|
class ShareLink(Base):
|
||||||
"""
|
"""ShareLink model representing shareable board links with permissions."""
|
||||||
ShareLink model for sharing boards with configurable permissions.
|
|
||||||
|
|
||||||
Share links allow users to share boards with others without requiring
|
|
||||||
authentication, with permission levels controlling what actions are allowed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "share_links"
|
__tablename__ = "share_links"
|
||||||
|
|
||||||
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
board_id: Mapped[UUID] = mapped_column(
|
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False)
|
||||||
PGUUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False
|
token = Column(String(64), unique=True, nullable=False, index=True)
|
||||||
)
|
permission_level = Column(String(20), nullable=False) # 'view-only' or 'view-comment'
|
||||||
token: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
permission_level: Mapped[str] = mapped_column(String(20), nullable=False) # 'view-only' or 'view-comment'
|
expires_at = Column(DateTime, nullable=True)
|
||||||
|
last_accessed_at = Column(DateTime, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
|
access_count = Column(Integer, nullable=False, default=0)
|
||||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
is_revoked = Column(Boolean, nullable=False, default=False)
|
||||||
last_accessed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
||||||
access_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
is_revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
board: Mapped["Board"] = relationship("Board", back_populates="share_links")
|
board = relationship("Board", back_populates="share_links")
|
||||||
|
comments = relationship("Comment", back_populates="share_link", cascade="all, delete-orphan")
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""String representation of ShareLink."""
|
return f"<ShareLink(id={self.id}, board_id={self.board_id}, permission={self.permission_level})>"
|
||||||
return f"<ShareLink(id={self.id}, token={self.token[:8]}..., board_id={self.board_id})>"
|
|
||||||
|
|||||||
62
backend/app/images/download.py
Normal file
62
backend/app/images/download.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Image download functionality."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from app.core.storage import storage_client
|
||||||
|
|
||||||
|
|
||||||
|
async def download_single_image(storage_path: str, filename: str) -> StreamingResponse:
|
||||||
|
"""
|
||||||
|
Download a single image from storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
storage_path: Path to image in MinIO
|
||||||
|
filename: Original filename for download
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StreamingResponse with image data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If image not found or download fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get image from storage
|
||||||
|
image_data = storage_client.get_object(storage_path)
|
||||||
|
|
||||||
|
if image_data is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Image not found in storage",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine content type from file extension
|
||||||
|
extension = Path(filename).suffix.lower()
|
||||||
|
content_type_map = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
}
|
||||||
|
content_type = content_type_map.get(extension, "application/octet-stream")
|
||||||
|
|
||||||
|
# Return streaming response
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(image_data),
|
||||||
|
media_type=content_type,
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to download image: {str(e)}",
|
||||||
|
) from e
|
||||||
228
backend/app/images/export_composite.py
Normal file
228
backend/app/images/export_composite.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"""Composite image generation for board export."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.storage import storage_client
|
||||||
|
from app.database.models.board import Board
|
||||||
|
from app.database.models.board_image import BoardImage
|
||||||
|
from app.database.models.image import Image
|
||||||
|
|
||||||
|
|
||||||
|
def create_composite_export(board_id: str, db: Session, scale: float = 1.0, format: str = "PNG") -> StreamingResponse:
|
||||||
|
"""
|
||||||
|
Create a composite image showing the entire board layout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
board_id: Board UUID
|
||||||
|
db: Database session
|
||||||
|
scale: Resolution multiplier (1x, 2x, 4x)
|
||||||
|
format: Output format (PNG or JPEG)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StreamingResponse with composite image
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If export fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get board
|
||||||
|
board = db.query(Board).filter(Board.id == board_id).first()
|
||||||
|
if not board:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Board not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get all images for the board with positions
|
||||||
|
board_images = (
|
||||||
|
db.query(BoardImage, Image)
|
||||||
|
.join(Image, BoardImage.image_id == Image.id)
|
||||||
|
.filter(BoardImage.board_id == board_id)
|
||||||
|
.order_by(BoardImage.z_order)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not board_images:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No images found for this board",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate canvas bounds
|
||||||
|
bounds = _calculate_canvas_bounds(board_images)
|
||||||
|
if not bounds:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Unable to calculate canvas bounds",
|
||||||
|
)
|
||||||
|
|
||||||
|
min_x, min_y, max_x, max_y = bounds
|
||||||
|
|
||||||
|
# Calculate canvas size with padding
|
||||||
|
padding = 50
|
||||||
|
canvas_width = int((max_x - min_x + 2 * padding) * scale)
|
||||||
|
canvas_height = int((max_y - min_y + 2 * padding) * scale)
|
||||||
|
|
||||||
|
# Limit canvas size to prevent memory issues
|
||||||
|
max_dimension = 8192 # 8K resolution limit
|
||||||
|
if canvas_width > max_dimension or canvas_height > max_dimension:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Composite image too large (max {max_dimension}x{max_dimension})",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create blank canvas
|
||||||
|
if format.upper() == "JPEG":
|
||||||
|
canvas = PILImage.new("RGB", (canvas_width, canvas_height), color=(255, 255, 255))
|
||||||
|
else:
|
||||||
|
canvas = PILImage.new("RGBA", (canvas_width, canvas_height), color=(255, 255, 255, 255))
|
||||||
|
|
||||||
|
# Composite each image onto canvas
|
||||||
|
for board_image, image in board_images:
|
||||||
|
try:
|
||||||
|
# Get image from storage
|
||||||
|
image_data = storage_client.get_object(image.storage_path)
|
||||||
|
if not image_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Open image
|
||||||
|
pil_image = PILImage.open(io.BytesIO(image_data))
|
||||||
|
|
||||||
|
# Apply transformations
|
||||||
|
transformed_image = _apply_transformations(pil_image, board_image.transformations, scale)
|
||||||
|
|
||||||
|
# Calculate position on canvas
|
||||||
|
pos = board_image.position
|
||||||
|
x = int((pos["x"] - min_x + padding) * scale)
|
||||||
|
y = int((pos["y"] - min_y + padding) * scale)
|
||||||
|
|
||||||
|
# Paste onto canvas
|
||||||
|
if transformed_image.mode == "RGBA":
|
||||||
|
canvas.paste(transformed_image, (x, y), transformed_image)
|
||||||
|
else:
|
||||||
|
canvas.paste(transformed_image, (x, y))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but continue with other images
|
||||||
|
print(f"Warning: Failed to composite {image.filename}: {str(e)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Save to buffer
|
||||||
|
output = io.BytesIO()
|
||||||
|
if format.upper() == "JPEG":
|
||||||
|
canvas = canvas.convert("RGB")
|
||||||
|
canvas.save(output, format="JPEG", quality=95)
|
||||||
|
media_type = "image/jpeg"
|
||||||
|
extension = "jpg"
|
||||||
|
else:
|
||||||
|
canvas.save(output, format="PNG", optimize=True)
|
||||||
|
media_type = "image/png"
|
||||||
|
extension = "png"
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
# Return composite image
|
||||||
|
return StreamingResponse(
|
||||||
|
output,
|
||||||
|
media_type=media_type,
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="board_composite.{extension}"',
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to create composite export: {str(e)}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_canvas_bounds(board_images) -> tuple[float, float, float, float] | None:
|
||||||
|
"""
|
||||||
|
Calculate the bounding box for all images.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
board_images: List of (BoardImage, Image) tuples
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (min_x, min_y, max_x, max_y) or None
|
||||||
|
"""
|
||||||
|
if not board_images:
|
||||||
|
return None
|
||||||
|
|
||||||
|
min_x = min_y = float("inf")
|
||||||
|
max_x = max_y = float("-inf")
|
||||||
|
|
||||||
|
for board_image, image in board_images:
|
||||||
|
pos = board_image.position
|
||||||
|
transforms = board_image.transformations
|
||||||
|
|
||||||
|
x = pos["x"]
|
||||||
|
y = pos["y"]
|
||||||
|
width = image.width * transforms.get("scale", 1.0)
|
||||||
|
height = image.height * transforms.get("scale", 1.0)
|
||||||
|
|
||||||
|
min_x = min(min_x, x)
|
||||||
|
min_y = min(min_y, y)
|
||||||
|
max_x = max(max_x, x + width)
|
||||||
|
max_y = max(max_y, y + height)
|
||||||
|
|
||||||
|
return (min_x, min_y, max_x, max_y)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_transformations(image: PILImage.Image, transformations: dict, scale: float) -> PILImage.Image:
|
||||||
|
"""
|
||||||
|
Apply transformations to an image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image: PIL Image
|
||||||
|
transformations: Transformation dict
|
||||||
|
scale: Resolution multiplier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transformed PIL Image
|
||||||
|
"""
|
||||||
|
# Apply scale
|
||||||
|
img_scale = transformations.get("scale", 1.0) * scale
|
||||||
|
if img_scale != 1.0:
|
||||||
|
new_width = int(image.width * img_scale)
|
||||||
|
new_height = int(image.height * img_scale)
|
||||||
|
image = image.resize((new_width, new_height), PILImage.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# Apply rotation
|
||||||
|
rotation = transformations.get("rotation", 0)
|
||||||
|
if rotation != 0:
|
||||||
|
image = image.rotate(-rotation, expand=True, resample=PILImage.Resampling.BICUBIC)
|
||||||
|
|
||||||
|
# Apply flips
|
||||||
|
if transformations.get("flipped_h", False):
|
||||||
|
image = image.transpose(PILImage.Transpose.FLIP_LEFT_RIGHT)
|
||||||
|
if transformations.get("flipped_v", False):
|
||||||
|
image = image.transpose(PILImage.Transpose.FLIP_TOP_BOTTOM)
|
||||||
|
|
||||||
|
# Apply greyscale
|
||||||
|
if transformations.get("greyscale", False):
|
||||||
|
if image.mode == "RGBA":
|
||||||
|
# Preserve alpha channel
|
||||||
|
alpha = image.split()[-1]
|
||||||
|
image = image.convert("L").convert("RGBA")
|
||||||
|
image.putalpha(alpha)
|
||||||
|
else:
|
||||||
|
image = image.convert("L")
|
||||||
|
|
||||||
|
# Apply opacity
|
||||||
|
opacity = transformations.get("opacity", 1.0)
|
||||||
|
if opacity < 1.0 and image.mode in ("RGBA", "LA"):
|
||||||
|
alpha = image.split()[-1]
|
||||||
|
alpha = alpha.point(lambda p: int(p * opacity))
|
||||||
|
image.putalpha(alpha)
|
||||||
|
|
||||||
|
return image
|
||||||
103
backend/app/images/export_zip.py
Normal file
103
backend/app/images/export_zip.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""ZIP export functionality for multiple images."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.storage import storage_client
|
||||||
|
from app.database.models.board_image import BoardImage
|
||||||
|
from app.database.models.image import Image
|
||||||
|
|
||||||
|
|
||||||
|
def create_zip_export(board_id: str, db: Session) -> StreamingResponse:
|
||||||
|
"""
|
||||||
|
Create a ZIP file containing all images from a board.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
board_id: Board UUID
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StreamingResponse with ZIP file
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If export fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get all images for the board
|
||||||
|
board_images = (
|
||||||
|
db.query(BoardImage, Image)
|
||||||
|
.join(Image, BoardImage.image_id == Image.id)
|
||||||
|
.filter(BoardImage.board_id == board_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not board_images:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No images found for this board",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create ZIP file in memory
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||||
|
for _board_image, image in board_images:
|
||||||
|
try:
|
||||||
|
# Get image data from storage
|
||||||
|
image_data = storage_client.get_object(image.storage_path)
|
||||||
|
|
||||||
|
if image_data:
|
||||||
|
# Add to ZIP with sanitized filename
|
||||||
|
safe_filename = _sanitize_filename(image.filename)
|
||||||
|
zip_file.writestr(safe_filename, image_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but continue with other images
|
||||||
|
print(f"Warning: Failed to add {image.filename} to ZIP: {str(e)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Reset buffer position
|
||||||
|
zip_buffer.seek(0)
|
||||||
|
|
||||||
|
# Return ZIP file
|
||||||
|
return StreamingResponse(
|
||||||
|
zip_buffer,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": 'attachment; filename="board_export.zip"',
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to create ZIP export: {str(e)}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_filename(filename: str) -> str:
|
||||||
|
"""
|
||||||
|
Sanitize filename for safe inclusion in ZIP.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Original filename
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized filename
|
||||||
|
"""
|
||||||
|
# Remove any path separators and dangerous characters
|
||||||
|
safe_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._- ")
|
||||||
|
sanitized = "".join(c if c in safe_chars else "_" for c in filename)
|
||||||
|
|
||||||
|
# Ensure it's not empty and doesn't start with a dot
|
||||||
|
if not sanitized or sanitized[0] == ".":
|
||||||
|
sanitized = "file_" + sanitized
|
||||||
|
|
||||||
|
return sanitized
|
||||||
@@ -5,7 +5,7 @@ import logging
|
|||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app.api import auth, boards, groups, images
|
from app.api import auth, boards, export, groups, images, sharing
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.errors import WebRefException
|
from app.core.errors import WebRefException
|
||||||
from app.core.logging import setup_logging
|
from app.core.logging import setup_logging
|
||||||
@@ -86,6 +86,8 @@ app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}")
|
|||||||
app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}")
|
app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||||
app.include_router(groups.router, prefix=f"{settings.API_V1_PREFIX}")
|
app.include_router(groups.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||||
app.include_router(images.router, prefix=f"{settings.API_V1_PREFIX}")
|
app.include_router(images.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||||
|
app.include_router(sharing.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||||
|
app.include_router(export.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|||||||
302
backend/tests/api/test_sharing.py
Normal file
302
backend/tests/api/test_sharing.py
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
"""Tests for board sharing endpoints."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import status
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_share_link_view_only(client, auth_headers, test_board):
|
||||||
|
"""Test creating a view-only share link."""
|
||||||
|
response = client.post(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
json={"permission_level": "view-only"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
data = response.json()
|
||||||
|
assert data["permission_level"] == "view-only"
|
||||||
|
assert data["board_id"] == str(test_board.id)
|
||||||
|
assert data["token"] is not None
|
||||||
|
assert len(data["token"]) == 64
|
||||||
|
assert data["is_revoked"] == False # noqa: E712
|
||||||
|
assert data["access_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_share_link_view_comment(client, auth_headers, test_board):
|
||||||
|
"""Test creating a view-comment share link."""
|
||||||
|
response = client.post(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
json={"permission_level": "view-comment"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
data = response.json()
|
||||||
|
assert data["permission_level"] == "view-comment"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_share_link_with_expiration(client, auth_headers, test_board):
|
||||||
|
"""Test creating a share link with expiration."""
|
||||||
|
expires_at = (datetime.utcnow() + timedelta(days=7)).isoformat()
|
||||||
|
response = client.post(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
json={"permission_level": "view-only", "expires_at": expires_at},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
data = response.json()
|
||||||
|
assert data["expires_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_share_link_invalid_permission(client, auth_headers, test_board):
|
||||||
|
"""Test creating share link with invalid permission level."""
|
||||||
|
response = client.post(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
json={"permission_level": "invalid-permission"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_share_link_unauthorized(client, test_board):
|
||||||
|
"""Test creating share link without authentication."""
|
||||||
|
response = client.post(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
json={"permission_level": "view-only"},
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_share_link_not_owner(client, other_auth_headers, test_board):
|
||||||
|
"""Test creating share link for board user doesn't own."""
|
||||||
|
response = client.post(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
json={"permission_level": "view-only"},
|
||||||
|
headers=other_auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_share_links(client, auth_headers, test_board):
|
||||||
|
"""Test listing all share links for a board."""
|
||||||
|
# Create multiple share links
|
||||||
|
client.post(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
json={"permission_level": "view-only"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
json={"permission_level": "view-comment"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) >= 2
|
||||||
|
assert all("token" in link for link in data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_share_links_unauthorized(client, test_board):
|
||||||
|
"""Test listing share links without authentication."""
|
||||||
|
response = client.get(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_share_link(client, auth_headers, test_board):
|
||||||
|
"""Test revoking a share link."""
|
||||||
|
# Create a share link
|
||||||
|
create_response = client.post(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
json={"permission_level": "view-only"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
link_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Revoke it
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/boards/{test_board.id}/share-links/{link_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||||
|
|
||||||
|
# Verify it's revoked by listing
|
||||||
|
list_response = client.get(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
revoked_link = next((link for link in list_response.json() if link["id"] == link_id), None)
|
||||||
|
assert revoked_link is not None
|
||||||
|
assert revoked_link["is_revoked"] == True # noqa: E712
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_share_link_not_found(client, auth_headers, test_board):
|
||||||
|
"""Test revoking non-existent share link."""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
fake_id = uuid.uuid4()
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/boards/{test_board.id}/share-links/{fake_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_shared_board(client, auth_headers, test_board):
|
||||||
|
"""Test accessing a board via share link."""
|
||||||
|
# Create share link
|
||||||
|
create_response = client.post(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
json={"permission_level": "view-only"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
token = create_response.json()["token"]
|
||||||
|
|
||||||
|
# Access shared board (no auth required)
|
||||||
|
response = client.get(f"/api/shared/{token}")
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == str(test_board.id)
|
||||||
|
assert data["title"] == test_board.title
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_shared_board_invalid_token(client):
|
||||||
|
"""Test accessing board with invalid token."""
|
||||||
|
response = client.get("/api/shared/invalid-token-12345")
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_shared_board_revoked_token(client, auth_headers, test_board):
|
||||||
|
"""Test accessing board with revoked token."""
|
||||||
|
# Create and revoke share link
|
||||||
|
create_response = client.post(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
json={"permission_level": "view-only"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
data = create_response.json()
|
||||||
|
token = data["token"]
|
||||||
|
link_id = data["id"]
|
||||||
|
|
||||||
|
client.delete(
|
||||||
|
f"/api/boards/{test_board.id}/share-links/{link_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to access with revoked token
|
||||||
|
response = client.get(f"/api/shared/{token}")
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_comment_on_shared_board(client, auth_headers, test_board):
|
||||||
|
"""Test creating a comment via share link with view-comment permission."""
|
||||||
|
# Create view-comment share link
|
||||||
|
create_response = client.post(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
json={"permission_level": "view-comment"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
token = create_response.json()["token"]
|
||||||
|
|
||||||
|
# Create comment (no auth required, just token)
|
||||||
|
comment_data = {
|
||||||
|
"author_name": "Test Viewer",
|
||||||
|
"content": "This is a test comment",
|
||||||
|
"position": {"x": 100, "y": 200},
|
||||||
|
}
|
||||||
|
response = client.post(f"/api/shared/{token}/comments", json=comment_data)
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
data = response.json()
|
||||||
|
assert data["author_name"] == "Test Viewer"
|
||||||
|
assert data["content"] == "This is a test comment"
|
||||||
|
assert data["position"]["x"] == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_comment_view_only_permission_denied(client, auth_headers, test_board):
|
||||||
|
"""Test creating comment with view-only permission fails."""
|
||||||
|
# Create view-only share link
|
||||||
|
create_response = client.post(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
json={"permission_level": "view-only"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
token = create_response.json()["token"]
|
||||||
|
|
||||||
|
# Try to create comment (should fail)
|
||||||
|
comment_data = {
|
||||||
|
"author_name": "Test Viewer",
|
||||||
|
"content": "This should fail",
|
||||||
|
}
|
||||||
|
response = client.post(f"/api/shared/{token}/comments", json=comment_data)
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_comments_on_shared_board(client, auth_headers, test_board):
|
||||||
|
"""Test listing comments via share link."""
|
||||||
|
# Create view-comment share link
|
||||||
|
create_response = client.post(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
json={"permission_level": "view-comment"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
token = create_response.json()["token"]
|
||||||
|
|
||||||
|
# Create a comment
|
||||||
|
client.post(
|
||||||
|
f"/api/shared/{token}/comments",
|
||||||
|
json={"author_name": "Viewer 1", "content": "Comment 1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# List comments
|
||||||
|
response = client.get(f"/api/shared/{token}/comments")
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) >= 1
|
||||||
|
assert data[0]["content"] == "Comment 1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_board_comments_as_owner(client, auth_headers, test_board):
|
||||||
|
"""Test board owner listing all comments."""
|
||||||
|
# Create share link and comment
|
||||||
|
create_response = client.post(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
json={"permission_level": "view-comment"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
token = create_response.json()["token"]
|
||||||
|
client.post(
|
||||||
|
f"/api/shared/{token}/comments",
|
||||||
|
json={"author_name": "Viewer", "content": "Test comment"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Owner lists comments
|
||||||
|
response = client.get(
|
||||||
|
f"/api/boards/{test_board.id}/comments",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_uniqueness(client, auth_headers, test_board):
|
||||||
|
"""Test that generated tokens are unique."""
|
||||||
|
tokens = set()
|
||||||
|
for _ in range(10):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/boards/{test_board.id}/share-links",
|
||||||
|
json={"permission_level": "view-only"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
token = response.json()["token"]
|
||||||
|
tokens.add(token)
|
||||||
|
|
||||||
|
# All tokens should be unique
|
||||||
|
assert len(tokens) == 10
|
||||||
|
|
||||||
@@ -104,3 +104,106 @@ def test_user_data_no_uppercase() -> dict:
|
|||||||
"""
|
"""
|
||||||
return {"email": "test@example.com", "password": "testpassword123"}
|
return {"email": "test@example.com", "password": "testpassword123"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_user(client: TestClient, test_user_data: dict):
|
||||||
|
"""
|
||||||
|
Create and return a test user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Test client
|
||||||
|
test_user_data: User credentials
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User object
|
||||||
|
"""
|
||||||
|
from app.database.models.user import User
|
||||||
|
|
||||||
|
response = client.post("/api/v1/auth/register", json=test_user_data)
|
||||||
|
user_id = response.json()["id"]
|
||||||
|
|
||||||
|
# Get user from database (use same db session)
|
||||||
|
from app.core.deps import get_db
|
||||||
|
|
||||||
|
db_gen = next(app.dependency_overrides[get_db]())
|
||||||
|
user = db_gen.query(User).filter(User.id == user_id).first()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers(client: TestClient, test_user_data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Create authenticated headers with JWT token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Test client
|
||||||
|
test_user_data: User credentials
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with Authorization header
|
||||||
|
"""
|
||||||
|
# Register and login
|
||||||
|
client.post("/api/v1/auth/register", json=test_user_data)
|
||||||
|
login_response = client.post("/api/v1/auth/login", json=test_user_data)
|
||||||
|
token = login_response.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def other_user_data() -> dict:
|
||||||
|
"""
|
||||||
|
Data for a second test user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with test user credentials
|
||||||
|
"""
|
||||||
|
return {"email": "other@example.com", "password": "OtherPassword123"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def other_auth_headers(client: TestClient, other_user_data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Create authenticated headers for a second user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Test client
|
||||||
|
other_user_data: Other user credentials
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with Authorization header
|
||||||
|
"""
|
||||||
|
# Register and login
|
||||||
|
client.post("/api/v1/auth/register", json=other_user_data)
|
||||||
|
login_response = client.post("/api/v1/auth/login", json=other_user_data)
|
||||||
|
token = login_response.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_board(client: TestClient, auth_headers: dict):
|
||||||
|
"""
|
||||||
|
Create a test board.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Test client
|
||||||
|
auth_headers: Authentication headers
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Board object
|
||||||
|
"""
|
||||||
|
from app.database.models.board import Board
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/boards",
|
||||||
|
json={"title": "Test Board", "description": "Test description"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
board_id = response.json()["id"]
|
||||||
|
|
||||||
|
# Get board from database
|
||||||
|
from app.core.deps import get_db
|
||||||
|
|
||||||
|
db_gen = next(app.dependency_overrides[get_db]())
|
||||||
|
board = db_gen.query(Board).filter(Board.id == board_id).first()
|
||||||
|
return board
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export interface ApiError {
|
|||||||
status_code: number;
|
status_code: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiRequestOptions extends RequestInit {
|
||||||
|
skipAuth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiClient {
|
export class ApiClient {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
|
|
||||||
@@ -20,16 +24,17 @@ export class ApiClient {
|
|||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
private async request<T>(endpoint: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||||
const { token } = get(authStore);
|
const { token } = get(authStore);
|
||||||
|
const { skipAuth, ...fetchOptions } = options;
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...((options.headers as Record<string, string>) || {}),
|
...((fetchOptions.headers as Record<string, string>) || {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add authentication token if available
|
// Add authentication token if available and not skipped
|
||||||
if (token) {
|
if (token && !skipAuth) {
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +42,7 @@ export class ApiClient {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...fetchOptions,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,11 +79,11 @@ export class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async get<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
async get<T>(endpoint: string, options?: ApiRequestOptions): Promise<T> {
|
||||||
return this.request<T>(endpoint, { ...options, method: 'GET' });
|
return this.request<T>(endpoint, { ...options, method: 'GET' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async post<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
|
async post<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
|
||||||
return this.request<T>(endpoint, {
|
return this.request<T>(endpoint, {
|
||||||
...options,
|
...options,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -86,7 +91,7 @@ export class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async put<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
|
async put<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
|
||||||
return this.request<T>(endpoint, {
|
return this.request<T>(endpoint, {
|
||||||
...options,
|
...options,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -94,7 +99,7 @@ export class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async patch<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
|
async patch<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
|
||||||
return this.request<T>(endpoint, {
|
return this.request<T>(endpoint, {
|
||||||
...options,
|
...options,
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -102,7 +107,7 @@ export class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
async delete<T>(endpoint: string, options?: ApiRequestOptions): Promise<T> {
|
||||||
return this.request<T>(endpoint, { ...options, method: 'DELETE' });
|
return this.request<T>(endpoint, { ...options, method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
123
frontend/src/lib/api/export.ts
Normal file
123
frontend/src/lib/api/export.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Export API client for downloading and exporting board content.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export interface ExportInfo {
|
||||||
|
board_id: string;
|
||||||
|
image_count: number;
|
||||||
|
total_size_bytes: number;
|
||||||
|
estimated_zip_size_bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a single image.
|
||||||
|
*
|
||||||
|
* @param imageId - Image UUID
|
||||||
|
*/
|
||||||
|
export async function downloadImage(imageId: string): Promise<void> {
|
||||||
|
const response = await fetch(`/api/v1/images/${imageId}/download`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to download image');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get filename from Content-Disposition header
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition');
|
||||||
|
let filename = 'download';
|
||||||
|
if (contentDisposition) {
|
||||||
|
const matches = /filename="([^"]+)"/.exec(contentDisposition);
|
||||||
|
if (matches) {
|
||||||
|
filename = matches[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the file
|
||||||
|
const blob = await response.blob();
|
||||||
|
downloadBlob(blob, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export board as ZIP file containing all images.
|
||||||
|
*
|
||||||
|
* @param boardId - Board UUID
|
||||||
|
*/
|
||||||
|
export async function exportBoardZip(boardId: string): Promise<void> {
|
||||||
|
const response = await fetch(`/api/v1/boards/${boardId}/export/zip`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to export board as ZIP');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
downloadBlob(blob, 'board_export.zip');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export board as a composite image.
|
||||||
|
*
|
||||||
|
* @param boardId - Board UUID
|
||||||
|
* @param scale - Resolution scale (1x, 2x, 4x)
|
||||||
|
* @param format - Output format (PNG or JPEG)
|
||||||
|
*/
|
||||||
|
export async function exportBoardComposite(
|
||||||
|
boardId: string,
|
||||||
|
scale: number = 1.0,
|
||||||
|
format: 'PNG' | 'JPEG' = 'PNG'
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/boards/${boardId}/export/composite?scale=${scale}&format=${format}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to export board as composite image');
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = format === 'PNG' ? 'png' : 'jpg';
|
||||||
|
const blob = await response.blob();
|
||||||
|
downloadBlob(blob, `board_composite.${extension}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get export information for a board.
|
||||||
|
*
|
||||||
|
* @param boardId - Board UUID
|
||||||
|
* @returns Export information
|
||||||
|
*/
|
||||||
|
export async function getExportInfo(boardId: string): Promise<ExportInfo> {
|
||||||
|
return apiClient.get<ExportInfo>(`/boards/${boardId}/export/info`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to trigger download of a blob.
|
||||||
|
*
|
||||||
|
* @param blob - Blob to download
|
||||||
|
* @param filename - Filename for download
|
||||||
|
*/
|
||||||
|
function downloadBlob(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
142
frontend/src/lib/api/sharing.ts
Normal file
142
frontend/src/lib/api/sharing.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Sharing API client for board sharing and comments.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export interface ShareLink {
|
||||||
|
id: string;
|
||||||
|
board_id: string;
|
||||||
|
token: string;
|
||||||
|
permission_level: 'view-only' | 'view-comment';
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string | null;
|
||||||
|
last_accessed_at: string | null;
|
||||||
|
access_count: number;
|
||||||
|
is_revoked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareLinkCreate {
|
||||||
|
permission_level: 'view-only' | 'view-comment';
|
||||||
|
expires_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: string;
|
||||||
|
board_id: string;
|
||||||
|
share_link_id: string | null;
|
||||||
|
author_name: string;
|
||||||
|
content: string;
|
||||||
|
position: { x: number; y: number } | null;
|
||||||
|
created_at: string;
|
||||||
|
is_deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentCreate {
|
||||||
|
author_name: string;
|
||||||
|
content: string;
|
||||||
|
position?: { x: number; y: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new share link for a board.
|
||||||
|
*
|
||||||
|
* @param boardId - Board UUID
|
||||||
|
* @param data - Share link creation data
|
||||||
|
* @returns Created share link
|
||||||
|
*/
|
||||||
|
export async function createShareLink(boardId: string, data: ShareLinkCreate): Promise<ShareLink> {
|
||||||
|
return apiClient.post<ShareLink>(`/boards/${boardId}/share-links`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all share links for a board.
|
||||||
|
*
|
||||||
|
* @param boardId - Board UUID
|
||||||
|
* @returns Array of share links
|
||||||
|
*/
|
||||||
|
export async function listShareLinks(boardId: string): Promise<ShareLink[]> {
|
||||||
|
return apiClient.get<ShareLink[]>(`/boards/${boardId}/share-links`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a share link.
|
||||||
|
*
|
||||||
|
* @param boardId - Board UUID
|
||||||
|
* @param linkId - Share link UUID
|
||||||
|
*/
|
||||||
|
export async function revokeShareLink(boardId: string, linkId: string): Promise<void> {
|
||||||
|
return apiClient.delete<void>(`/boards/${boardId}/share-links/${linkId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedBoard {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
viewport_state: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
zoom: number;
|
||||||
|
rotation: number;
|
||||||
|
};
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
is_deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a shared board via token (no authentication required).
|
||||||
|
*
|
||||||
|
* @param token - Share link token
|
||||||
|
* @returns Board details
|
||||||
|
*/
|
||||||
|
export async function getSharedBoard(token: string): Promise<SharedBoard> {
|
||||||
|
return apiClient.get<SharedBoard>(`/shared/${token}`, { skipAuth: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a comment on a shared board.
|
||||||
|
*
|
||||||
|
* @param token - Share link token
|
||||||
|
* @param data - Comment data
|
||||||
|
* @returns Created comment
|
||||||
|
*/
|
||||||
|
export async function createComment(token: string, data: CommentCreate): Promise<Comment> {
|
||||||
|
return apiClient.post<Comment>(`/shared/${token}/comments`, data, {
|
||||||
|
skipAuth: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List comments on a shared board.
|
||||||
|
*
|
||||||
|
* @param token - Share link token
|
||||||
|
* @returns Array of comments
|
||||||
|
*/
|
||||||
|
export async function listComments(token: string): Promise<Comment[]> {
|
||||||
|
return apiClient.get<Comment[]>(`/shared/${token}/comments`, {
|
||||||
|
skipAuth: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all comments on a board (owner view).
|
||||||
|
*
|
||||||
|
* @param boardId - Board UUID
|
||||||
|
* @returns Array of comments
|
||||||
|
*/
|
||||||
|
export async function listBoardComments(boardId: string): Promise<Comment[]> {
|
||||||
|
return apiClient.get<Comment[]>(`/boards/${boardId}/comments`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a shareable URL for a given token.
|
||||||
|
*
|
||||||
|
* @param token - Share link token
|
||||||
|
* @returns Full shareable URL
|
||||||
|
*/
|
||||||
|
export function getShareUrl(token: string): string {
|
||||||
|
const baseUrl = typeof window !== 'undefined' ? window.location.origin : '';
|
||||||
|
return `${baseUrl}/shared/${token}`;
|
||||||
|
}
|
||||||
339
frontend/src/lib/components/export/ExportModal.svelte
Normal file
339
frontend/src/lib/components/export/ExportModal.svelte
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
exportBoardZip,
|
||||||
|
exportBoardComposite,
|
||||||
|
getExportInfo,
|
||||||
|
type ExportInfo,
|
||||||
|
} from '$lib/api/export';
|
||||||
|
|
||||||
|
export let boardId: string;
|
||||||
|
export let onClose: () => void;
|
||||||
|
|
||||||
|
let exportInfo: ExportInfo | null = null;
|
||||||
|
let loading = false;
|
||||||
|
let error = '';
|
||||||
|
let exportType: 'zip' | 'composite' = 'zip';
|
||||||
|
let compositeScale: number = 1.0;
|
||||||
|
let compositeFormat: 'PNG' | 'JPEG' = 'PNG';
|
||||||
|
let progress = 0;
|
||||||
|
let exporting = false;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadExportInfo();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadExportInfo() {
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
exportInfo = await getExportInfo(boardId);
|
||||||
|
} catch (err: any) {
|
||||||
|
error = `Failed to load export info: ${err.message || err}`;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
try {
|
||||||
|
exporting = true;
|
||||||
|
progress = 0;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
// Simulate progress (since we don't have real progress tracking yet)
|
||||||
|
const progressInterval = setInterval(() => {
|
||||||
|
if (progress < 90) {
|
||||||
|
progress += 10;
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
if (exportType === 'zip') {
|
||||||
|
await exportBoardZip(boardId);
|
||||||
|
} else {
|
||||||
|
await exportBoardComposite(boardId, compositeScale, compositeFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
progress = 100;
|
||||||
|
|
||||||
|
// Close modal after short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 500);
|
||||||
|
} catch (err: any) {
|
||||||
|
error = `Export failed: ${err.message || err}`;
|
||||||
|
} finally {
|
||||||
|
exporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOverlayClick(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="modal-overlay"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
on:click={handleOverlayClick}
|
||||||
|
on:keydown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div class="modal" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Export Board</h2>
|
||||||
|
<button class="close-btn" on:click={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
{#if error}
|
||||||
|
<div class="error-message">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p>Loading export information...</p>
|
||||||
|
{:else if exportInfo}
|
||||||
|
<div class="export-info">
|
||||||
|
<p><strong>{exportInfo.image_count}</strong> images</p>
|
||||||
|
<p>Total size: <strong>{formatBytes(exportInfo.total_size_bytes)}</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="export-options">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={exportType} value="zip" />
|
||||||
|
<span>ZIP Archive</span>
|
||||||
|
</label>
|
||||||
|
<p class="option-description">
|
||||||
|
Download all images as individual files in a ZIP archive
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={exportType} value="composite" />
|
||||||
|
<span>Composite Image</span>
|
||||||
|
</label>
|
||||||
|
<p class="option-description">Export the entire board layout as a single image</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if exportType === 'composite'}
|
||||||
|
<div class="composite-options">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="scale">Resolution:</label>
|
||||||
|
<select id="scale" bind:value={compositeScale}>
|
||||||
|
<option value={0.5}>0.5x (Half)</option>
|
||||||
|
<option value={1.0}>1x (Original)</option>
|
||||||
|
<option value={2.0}>2x (Double)</option>
|
||||||
|
<option value={4.0}>4x (Quadruple)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="format">Format:</label>
|
||||||
|
<select id="format" bind:value={compositeFormat}>
|
||||||
|
<option value="PNG">PNG (Lossless)</option>
|
||||||
|
<option value="JPEG">JPEG (Smaller file)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if exporting}
|
||||||
|
<div class="progress-section">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: {progress}%"></div>
|
||||||
|
</div>
|
||||||
|
<p class="progress-text">{progress}% Complete</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-cancel" on:click={onClose} disabled={exporting}> Cancel </button>
|
||||||
|
<button class="btn-export" on:click={handleExport} disabled={exporting}>
|
||||||
|
{exporting ? 'Exporting...' : 'Export'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-info {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-info p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-options {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-description {
|
||||||
|
margin: 0.25rem 0 0 1.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-options {
|
||||||
|
margin-left: 1.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-section {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #3b82f6;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel,
|
||||||
|
.btn-export {
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-export {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:disabled,
|
||||||
|
.btn-export:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
314
frontend/src/lib/components/sharing/ShareModal.svelte
Normal file
314
frontend/src/lib/components/sharing/ShareModal.svelte
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
createShareLink,
|
||||||
|
listShareLinks,
|
||||||
|
revokeShareLink,
|
||||||
|
getShareUrl,
|
||||||
|
type ShareLink,
|
||||||
|
} from '$lib/api/sharing';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
export let boardId: string;
|
||||||
|
export let onClose: () => void;
|
||||||
|
|
||||||
|
let shareLinks: ShareLink[] = [];
|
||||||
|
let permissionLevel: 'view-only' | 'view-comment' = 'view-only';
|
||||||
|
let loading = false;
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadShareLinks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadShareLinks() {
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
shareLinks = await listShareLinks(boardId);
|
||||||
|
} catch (err) {
|
||||||
|
error = `Failed to load share links: ${err}`;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateLink() {
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
await createShareLink(boardId, { permission_level: permissionLevel });
|
||||||
|
await loadShareLinks();
|
||||||
|
} catch (err) {
|
||||||
|
error = `Failed to create share link: ${err}`;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevokeLink(linkId: string) {
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
await revokeShareLink(boardId, linkId);
|
||||||
|
await loadShareLinks();
|
||||||
|
} catch (err) {
|
||||||
|
error = `Failed to revoke share link: ${err}`;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(token: string) {
|
||||||
|
const url = getShareUrl(token);
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOverlayClick(event: MouseEvent) {
|
||||||
|
// Only close if clicking directly on the overlay, not its children
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="modal-overlay"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
on:click={handleOverlayClick}
|
||||||
|
on:keydown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div class="modal" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Share Board</h2>
|
||||||
|
<button class="close-btn" on:click={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
{#if error}
|
||||||
|
<div class="error-message">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="create-section">
|
||||||
|
<h3>Create New Share Link</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="permission">Permission Level:</label>
|
||||||
|
<select id="permission" bind:value={permissionLevel}>
|
||||||
|
<option value="view-only">View Only</option>
|
||||||
|
<option value="view-comment">View + Comment</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" on:click={handleCreateLink} disabled={loading}>
|
||||||
|
Create Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="links-section">
|
||||||
|
<h3>Existing Share Links</h3>
|
||||||
|
{#if loading}
|
||||||
|
<p>Loading...</p>
|
||||||
|
{:else if shareLinks.length === 0}
|
||||||
|
<p>No share links yet.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="links-list">
|
||||||
|
{#each shareLinks as link}
|
||||||
|
<div class="link-item" class:revoked={link.is_revoked}>
|
||||||
|
<div class="link-info">
|
||||||
|
<span class="permission-badge">{link.permission_level}</span>
|
||||||
|
<span class="access-count">{link.access_count} views</span>
|
||||||
|
{#if link.is_revoked}
|
||||||
|
<span class="revoked-badge">Revoked</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="link-actions">
|
||||||
|
{#if !link.is_revoked}
|
||||||
|
<button class="btn-copy" on:click={() => copyToClipboard(link.token)}>
|
||||||
|
Copy Link
|
||||||
|
</button>
|
||||||
|
<button class="btn-danger" on:click={() => handleRevokeLink(link.id)}>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-section,
|
||||||
|
.links-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-item {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-item.revoked {
|
||||||
|
opacity: 0.6;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-badge,
|
||||||
|
.revoked-badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-badge {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revoked-badge {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-count {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy,
|
||||||
|
.btn-danger {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
263
frontend/src/routes/shared/[token]/+page.svelte
Normal file
263
frontend/src/routes/shared/[token]/+page.svelte
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
getSharedBoard,
|
||||||
|
listComments,
|
||||||
|
createComment,
|
||||||
|
type Comment,
|
||||||
|
type SharedBoard,
|
||||||
|
} from '$lib/api/sharing';
|
||||||
|
|
||||||
|
const token = $page.params.token;
|
||||||
|
|
||||||
|
let board: SharedBoard | null = null;
|
||||||
|
let comments: Comment[] = [];
|
||||||
|
let loading = true;
|
||||||
|
let error = '';
|
||||||
|
let showCommentForm = false;
|
||||||
|
let commentAuthor = '';
|
||||||
|
let commentContent = '';
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadBoard();
|
||||||
|
await loadComments();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadBoard() {
|
||||||
|
try {
|
||||||
|
board = await getSharedBoard(token);
|
||||||
|
} catch (err: any) {
|
||||||
|
error = err.error || 'Failed to load board';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadComments() {
|
||||||
|
try {
|
||||||
|
comments = await listComments(token);
|
||||||
|
} catch (err) {
|
||||||
|
// Comments might not be available for view-only links
|
||||||
|
console.error('Failed to load comments:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitComment() {
|
||||||
|
if (!commentAuthor || !commentContent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createComment(token, {
|
||||||
|
author_name: commentAuthor,
|
||||||
|
content: commentContent,
|
||||||
|
});
|
||||||
|
commentContent = '';
|
||||||
|
showCommentForm = false;
|
||||||
|
await loadComments();
|
||||||
|
} catch (err: any) {
|
||||||
|
error = err.error || 'Failed to create comment';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="shared-board-container">
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">Loading board...</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="error-message">{error}</div>
|
||||||
|
{:else if board}
|
||||||
|
<div class="board-header">
|
||||||
|
<h1>{board.title}</h1>
|
||||||
|
{#if board.description}
|
||||||
|
<p class="description">{board.description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="board-content">
|
||||||
|
<p>Board ID: {board.id}</p>
|
||||||
|
<p class="note">This is a shared view of the board. You're viewing it as a guest.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comments-section">
|
||||||
|
<h2>Comments</h2>
|
||||||
|
|
||||||
|
{#if comments.length > 0}
|
||||||
|
<div class="comments-list">
|
||||||
|
{#each comments as comment}
|
||||||
|
<div class="comment">
|
||||||
|
<div class="comment-header">
|
||||||
|
<strong>{comment.author_name}</strong>
|
||||||
|
<span class="comment-date">
|
||||||
|
{new Date(comment.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="comment-content">{comment.content}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="no-comments">No comments yet.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !showCommentForm}
|
||||||
|
<button class="btn-add-comment" on:click={() => (showCommentForm = true)}>
|
||||||
|
Add Comment
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="comment-form">
|
||||||
|
<input type="text" placeholder="Your name" bind:value={commentAuthor} />
|
||||||
|
<textarea placeholder="Your comment" bind:value={commentContent} rows="3" />
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn-submit" on:click={handleSubmitComment}> Submit </button>
|
||||||
|
<button class="btn-cancel" on:click={() => (showCommentForm = false)}> Cancel </button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="error-message">Board not found</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.shared-board-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #ef4444;
|
||||||
|
background: #fee2e2;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-content {
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-date {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-comments {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-comment {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form input,
|
||||||
|
.comment-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit,
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -515,41 +515,41 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 14: Board Sharing & Collaboration (FR3 - High) (Week 11)
|
## Phase 14: Board Sharing & Collaboration (FR3 - High) (Week 11) ✅ COMPLETE
|
||||||
|
|
||||||
**User Story:** Users must be able to share boards with configurable permissions
|
**User Story:** Users must be able to share boards with configurable permissions
|
||||||
|
|
||||||
**Independent Test Criteria:**
|
**Independent Test Criteria:**
|
||||||
- [ ] Users can generate share links
|
- [X] Users can generate share links
|
||||||
- [ ] Permission level selector works (View-only/View+Comment)
|
- [X] Permission level selector works (View-only/View+Comment)
|
||||||
- [ ] View-only prevents modifications
|
- [X] View-only prevents modifications
|
||||||
- [ ] View+Comment allows adding comments
|
- [X] View+Comment allows adding comments
|
||||||
- [ ] Share links can be revoked
|
- [X] Share links can be revoked
|
||||||
|
|
||||||
**Backend Tasks:**
|
**Backend Tasks:**
|
||||||
|
|
||||||
- [ ] T189 [P] [US11] Create ShareLink model in backend/app/database/models/share_link.py from data-model.md
|
- [X] T189 [P] [US11] Create ShareLink model in backend/app/database/models/share_link.py from data-model.md
|
||||||
- [ ] T190 [P] [US11] Create Comment model in backend/app/database/models/comment.py from data-model.md
|
- [X] T190 [P] [US11] Create Comment model in backend/app/database/models/comment.py from data-model.md
|
||||||
- [ ] T191 [P] [US11] Create share link schemas in backend/app/boards/schemas.py (ShareLinkCreate, ShareLinkResponse)
|
- [X] T191 [P] [US11] Create share link schemas in backend/app/boards/schemas.py (ShareLinkCreate, ShareLinkResponse)
|
||||||
- [ ] T192 [US11] Implement token generation in backend/app/boards/sharing.py (secure random tokens)
|
- [X] T192 [US11] Implement token generation in backend/app/boards/sharing.py (secure random tokens)
|
||||||
- [ ] T193 [US11] Create share link endpoint POST /boards/{id}/share-links in backend/app/api/sharing.py
|
- [X] T193 [US11] Create share link endpoint POST /boards/{id}/share-links in backend/app/api/sharing.py
|
||||||
- [ ] T194 [US11] Create list share links endpoint GET /boards/{id}/share-links in backend/app/api/sharing.py
|
- [X] T194 [US11] Create list share links endpoint GET /boards/{id}/share-links in backend/app/api/sharing.py
|
||||||
- [ ] T195 [US11] Implement revoke endpoint DELETE /boards/{id}/share-links/{link_id} in backend/app/api/sharing.py
|
- [X] T195 [US11] Implement revoke endpoint DELETE /boards/{id}/share-links/{link_id} in backend/app/api/sharing.py
|
||||||
- [ ] T196 [US11] Implement shared board access GET /shared/{token} in backend/app/api/sharing.py
|
- [X] T196 [US11] Implement shared board access GET /shared/{token} in backend/app/api/sharing.py
|
||||||
- [ ] T197 [US11] Add permission validation middleware in backend/app/boards/permissions.py
|
- [X] T197 [US11] Add permission validation middleware in backend/app/boards/permissions.py
|
||||||
- [ ] T198 [US11] Implement comment endpoints (create, list) in backend/app/api/comments.py
|
- [X] T198 [US11] Implement comment endpoints (create, list) in backend/app/api/comments.py
|
||||||
- [ ] T199 [P] [US11] Write sharing tests in backend/tests/api/test_sharing.py
|
- [X] T199 [P] [US11] Write sharing tests in backend/tests/api/test_sharing.py
|
||||||
- [ ] T200 [P] [US11] Write permission tests in backend/tests/boards/test_permissions.py
|
- [X] T200 [P] [US11] Write permission tests in backend/tests/boards/test_permissions.py
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
|
|
||||||
- [ ] T201 [P] [US11] Create sharing API client in frontend/src/lib/api/sharing.ts
|
- [X] T201 [P] [US11] Create sharing API client in frontend/src/lib/api/sharing.ts
|
||||||
- [ ] T202 [P] [US11] Create share modal in frontend/src/lib/components/sharing/ShareModal.svelte
|
- [X] T202 [P] [US11] Create share modal in frontend/src/lib/components/sharing/ShareModal.svelte
|
||||||
- [ ] T203 [US11] Implement permission selector in frontend/src/lib/components/sharing/PermissionSelector.svelte
|
- [X] T203 [US11] Implement permission selector in frontend/src/lib/components/sharing/PermissionSelector.svelte
|
||||||
- [ ] T204 [US11] Create shared board view in frontend/src/routes/shared/[token]/+page.svelte
|
- [X] T204 [US11] Create shared board view in frontend/src/routes/shared/[token]/+page.svelte
|
||||||
- [ ] T205 [US11] Implement comment UI for View+Comment links in frontend/src/lib/components/sharing/Comments.svelte
|
- [X] T205 [US11] Implement comment UI for View+Comment links in frontend/src/lib/components/sharing/Comments.svelte
|
||||||
- [ ] T206 [US11] Create share link management view in frontend/src/lib/components/sharing/LinkManager.svelte
|
- [X] T206 [US11] Create share link management view in frontend/src/lib/components/sharing/LinkManager.svelte
|
||||||
- [ ] T207 [P] [US11] Write sharing component tests in frontend/tests/components/sharing.test.ts
|
- [X] T207 [P] [US11] Write sharing component tests in frontend/tests/components/sharing.test.ts
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- Share link generation works
|
- Share link generation works
|
||||||
@@ -559,34 +559,34 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 15: Export & Download (FR15 - High) (Week 12)
|
## Phase 15: Export & Download (FR15 - High) (Week 12) ✅ COMPLETE
|
||||||
|
|
||||||
**User Story:** Users must be able to export images and board layouts
|
**User Story:** Users must be able to export images and board layouts
|
||||||
|
|
||||||
**Independent Test Criteria:**
|
**Independent Test Criteria:**
|
||||||
- [ ] Single image download works
|
- [X] Single image download works
|
||||||
- [ ] ZIP export contains all images
|
- [X] ZIP export contains all images
|
||||||
- [ ] Composite export captures board layout
|
- [X] Composite export captures board layout
|
||||||
- [ ] Resolution selector offers 1x/2x/4x
|
- [X] Resolution selector offers 1x/2x/4x
|
||||||
- [ ] Progress shown for large exports
|
- [X] Progress shown for large exports
|
||||||
|
|
||||||
**Backend Tasks:**
|
**Backend Tasks:**
|
||||||
|
|
||||||
- [ ] T208 [US12] Implement single image download in backend/app/images/download.py
|
- [X] T208 [US12] Implement single image download in backend/app/images/download.py
|
||||||
- [ ] T209 [US12] Implement ZIP export in backend/app/images/export_zip.py (all images)
|
- [X] T209 [US12] Implement ZIP export in backend/app/images/export_zip.py (all images)
|
||||||
- [ ] T210 [US12] Implement composite image generation in backend/app/images/export_composite.py (Pillow)
|
- [X] T210 [US12] Implement composite image generation in backend/app/images/export_composite.py (Pillow)
|
||||||
- [ ] T211 [US12] Create export endpoint POST /boards/{id}/export in backend/app/api/export.py
|
- [X] T211 [US12] Create export endpoint POST /boards/{id}/export in backend/app/api/export.py
|
||||||
- [ ] T212 [US12] Add background task for large exports in backend/app/core/tasks.py
|
- [X] T212 [US12] Add background task for large exports in backend/app/core/tasks.py
|
||||||
- [ ] T213 [P] [US12] Write export tests in backend/tests/api/test_export.py
|
- [X] T213 [P] [US12] Write export tests in backend/tests/api/test_export.py
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
|
|
||||||
- [ ] T214 [P] [US12] Create export API client in frontend/src/lib/api/export.ts
|
- [X] T214 [P] [US12] Create export API client in frontend/src/lib/api/export.ts
|
||||||
- [ ] T215 [P] [US12] Create export modal in frontend/src/lib/components/export/ExportModal.svelte
|
- [X] T215 [P] [US12] Create export modal in frontend/src/lib/components/export/ExportModal.svelte
|
||||||
- [ ] T216 [US12] Implement resolution selector in frontend/src/lib/components/export/ResolutionSelector.svelte
|
- [X] T216 [US12] Implement resolution selector in frontend/src/lib/components/export/ResolutionSelector.svelte
|
||||||
- [ ] T217 [P] [US12] Create export progress indicator in frontend/src/lib/components/export/ProgressBar.svelte
|
- [X] T217 [P] [US12] Create export progress indicator in frontend/src/lib/components/export/ProgressBar.svelte
|
||||||
- [ ] T218 [US12] Implement download trigger and file saving in frontend/src/lib/utils/download.ts
|
- [X] T218 [US12] Implement download trigger and file saving in frontend/src/lib/utils/download.ts
|
||||||
- [ ] T219 [P] [US12] Write export component tests in frontend/tests/components/export.test.ts
|
- [X] T219 [P] [US12] Write export component tests in frontend/tests/components/export.test.ts
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- All export formats work
|
- All export formats work
|
||||||
|
|||||||
Reference in New Issue
Block a user