diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b7bbc39 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# PostgreSQL — async DSN for SQLAlchemy + asyncpg +DATABASE_URL=postgresql+asyncpg://reactbin:reactbin@postgres:5432/reactbin + +# S3-compatible object storage (MinIO in local dev) +S3_ENDPOINT_URL=http://minio:9000 +S3_BUCKET_NAME=reactbin +S3_ACCESS_KEY_ID=minioadmin +S3_SECRET_ACCESS_KEY=minioadmin +S3_REGION=us-east-1 + +# Angular SPA — injected at build or runtime +API_BASE_URL=http://localhost:8000 + +# Upload size limit in bytes (default 50 MiB) +MAX_UPLOAD_BYTES=52428800 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69b148 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Environment +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Node / Angular +node_modules/ +dist/ +.angular/ + +# Docker +*.log + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Claude Code +.claude/ diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 0000000..691316d --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,14 @@ +.git/ +.venv/ +venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ +*.egg-info/ +dist/ +.env +.env.* +!.env.example diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..eea1f33 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN pip install --no-cache-dir uv + +COPY pyproject.toml . +RUN uv pip install --system --no-cache -e ".[dev]" + +COPY . . + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/api/alembic.ini b/api/alembic.ini new file mode 100644 index 0000000..4481cf8 --- /dev/null +++ b/api/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = driver://user:pass@localhost/dbname + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/api/alembic/env.py b/api/alembic/env.py new file mode 100644 index 0000000..65422d6 --- /dev/null +++ b/api/alembic/env.py @@ -0,0 +1,60 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +from app.config import get_settings +from app.database import Base +import app.models # noqa: F401 — ensure all models are imported + +config = context.config +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/api/alembic/script.py.mako b/api/alembic/script.py.mako new file mode 100644 index 0000000..17dcba0 --- /dev/null +++ b/api/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/api/alembic/versions/001_initial_schema.py b/api/alembic/versions/001_initial_schema.py new file mode 100644 index 0000000..4d64394 --- /dev/null +++ b/api/alembic/versions/001_initial_schema.py @@ -0,0 +1,63 @@ +"""initial schema — images, tags, image_tags + +Revision ID: 001 +Revises: +Create Date: 2026-05-02 + +""" +from typing import Sequence, Union +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from alembic import op + +revision: str = "001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "images", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("hash", sa.String(64), nullable=False), + sa.Column("filename", sa.String(), nullable=False), + sa.Column("mime_type", sa.String(20), nullable=False), + sa.Column("size_bytes", sa.BigInteger(), nullable=False), + sa.Column("width", sa.Integer(), nullable=False), + sa.Column("height", sa.Integer(), nullable=False), + sa.Column("storage_key", sa.String(64), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("hash", name="uq_images_hash"), + ) + op.create_index("ix_images_hash", "images", ["hash"]) + + op.create_table( + "tags", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("name", sa.String(64), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name", name="uq_tags_name"), + ) + op.create_index("ix_tags_name", "tags", ["name"]) + op.create_index("ix_tags_name_prefix", "tags", ["name"], postgresql_ops={"name": "varchar_pattern_ops"}) + + op.create_table( + "image_tags", + sa.Column("image_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("tag_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.ForeignKeyConstraint(["image_id"], ["images.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["tag_id"], ["tags.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("image_id", "tag_id"), + sa.UniqueConstraint("image_id", "tag_id", name="uq_image_tag"), + ) + op.create_index("ix_image_tags_image_id", "image_tags", ["image_id"]) + op.create_index("ix_image_tags_tag_id", "image_tags", ["tag_id"]) + + +def downgrade() -> None: + op.drop_table("image_tags") + op.drop_table("tags") + op.drop_table("images") diff --git a/api/app/__init__.py b/api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/auth/__init__.py b/api/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/auth/noop.py b/api/app/auth/noop.py new file mode 100644 index 0000000..397cbfe --- /dev/null +++ b/api/app/auth/noop.py @@ -0,0 +1,8 @@ +from app.auth.provider import AuthProvider, Identity + +_ANONYMOUS = Identity(id="anonymous", anonymous=True) + + +class NoOpAuthProvider(AuthProvider): + async def get_identity(self) -> Identity: + return _ANONYMOUS diff --git a/api/app/auth/provider.py b/api/app/auth/provider.py new file mode 100644 index 0000000..d16aa65 --- /dev/null +++ b/api/app/auth/provider.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +@dataclass +class Identity: + id: str + anonymous: bool = True + + +class AuthProvider(ABC): + @abstractmethod + async def get_identity(self) -> Identity: + """Resolve the request identity.""" diff --git a/api/app/config.py b/api/app/config.py new file mode 100644 index 0000000..f6ea193 --- /dev/null +++ b/api/app/config.py @@ -0,0 +1,20 @@ +from functools import lru_cache +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + database_url: str + s3_endpoint_url: str + s3_bucket_name: str + s3_access_key_id: str + s3_secret_access_key: str + s3_region: str = "us-east-1" + api_base_url: str = "http://localhost:8000" + max_upload_bytes: int = 52_428_800 # 50 MiB + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/api/app/database.py b/api/app/database.py new file mode 100644 index 0000000..72419cb --- /dev/null +++ b/api/app/database.py @@ -0,0 +1,26 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase + +from app.config import get_settings + +_engine = None +_session_factory = None + + +class Base(DeclarativeBase): + pass + + +def get_engine(): + global _engine + if _engine is None: + settings = get_settings() + _engine = create_async_engine(settings.database_url, echo=False) + return _engine + + +def get_session_factory(): + global _session_factory + if _session_factory is None: + _session_factory = async_sessionmaker(get_engine(), expire_on_commit=False) + return _session_factory diff --git a/api/app/dependencies.py b/api/app/dependencies.py new file mode 100644 index 0000000..714195a --- /dev/null +++ b/api/app/dependencies.py @@ -0,0 +1,34 @@ +from typing import AsyncGenerator + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.noop import NoOpAuthProvider +from app.auth.provider import AuthProvider +from app.database import get_session_factory +from app.storage.backend import StorageBackend +from app.storage.s3_backend import S3StorageBackend + +_storage: StorageBackend | None = None +_auth: AuthProvider | None = None + + +def get_storage() -> StorageBackend: + global _storage + if _storage is None: + _storage = S3StorageBackend() + return _storage + + +def get_auth() -> AuthProvider: + global _auth + if _auth is None: + _auth = NoOpAuthProvider() + return _auth + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + factory = get_session_factory() + async with factory() as session: + async with session.begin(): + yield session diff --git a/api/app/main.py b/api/app/main.py new file mode 100644 index 0000000..b2a7902 --- /dev/null +++ b/api/app/main.py @@ -0,0 +1,33 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.config import get_settings +from app.database import get_engine, get_session_factory, Base + + +@asynccontextmanager +async def lifespan(application: FastAPI): + settings = get_settings() + # Verify DB connection and run migrations on startup + engine = get_engine() + async with engine.begin() as conn: + # In production, Alembic handles migrations; this is a dev convenience + await conn.run_sync(Base.metadata.create_all) + yield + await engine.dispose() + + +app = FastAPI(title="Reactbin API", version="1.0.0", lifespan=lifespan) + + +@app.get("/api/v1/health") +async def health(): + return {"status": "ok"} + + +# Routers registered after all modules are defined to avoid circular imports +from app.routers import images, tags # noqa: E402 + +app.include_router(images.router, prefix="/api/v1") +app.include_router(tags.router, prefix="/api/v1") diff --git a/api/app/models.py b/api/app/models.py new file mode 100644 index 0000000..ab6af5a --- /dev/null +++ b/api/app/models.py @@ -0,0 +1,61 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import String, Integer, BigInteger, DateTime, ForeignKey, UniqueConstraint, Index +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class Image(Base): + __tablename__ = "images" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) + filename: Mapped[str] = mapped_column(String, nullable=False) + mime_type: Mapped[str] = mapped_column(String(20), nullable=False) + size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) + width: Mapped[int] = mapped_column(Integer, nullable=False) + height: Mapped[int] = mapped_column(Integer, nullable=False) + storage_key: Mapped[str] = mapped_column(String(64), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False) + + image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="image", cascade="all, delete-orphan") + + @property + def tags(self) -> list[str]: + return [it.tag.name for it in self.image_tags if it.tag] + + +class Tag(Base): + __tablename__ = "tags" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False) + + image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="tag") + + +class ImageTag(Base): + __tablename__ = "image_tags" + __table_args__ = ( + UniqueConstraint("image_id", "tag_id", name="uq_image_tag"), + Index("ix_image_tags_image_id", "image_id"), + Index("ix_image_tags_tag_id", "tag_id"), + ) + + image_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("images.id", ondelete="CASCADE"), primary_key=True + ) + tag_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("tags.id", ondelete="RESTRICT"), primary_key=True + ) + + image: Mapped["Image"] = relationship(back_populates="image_tags") + tag: Mapped["Tag"] = relationship(back_populates="image_tags") diff --git a/api/app/repositories/__init__.py b/api/app/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/repositories/image_repo.py b/api/app/repositories/image_repo.py new file mode 100644 index 0000000..9823b0f --- /dev/null +++ b/api/app/repositories/image_repo.py @@ -0,0 +1,84 @@ +import uuid +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models import Image, ImageTag, Tag + + +class ImageRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_hash(self, hash_hex: str) -> Optional[Image]: + result = await self._session.execute( + select(Image).where(Image.hash == hash_hex).options(selectinload(Image.image_tags).selectinload(ImageTag.tag)) + ) + return result.scalar_one_or_none() + + async def get_by_id(self, image_id: uuid.UUID) -> Optional[Image]: + result = await self._session.execute( + select(Image).where(Image.id == image_id).options(selectinload(Image.image_tags).selectinload(ImageTag.tag)) + ) + return result.scalar_one_or_none() + + async def create( + self, + *, + hash_hex: str, + filename: str, + mime_type: str, + size_bytes: int, + width: int, + height: int, + storage_key: str, + ) -> Image: + image = Image( + hash=hash_hex, + filename=filename, + mime_type=mime_type, + size_bytes=size_bytes, + width=width, + height=height, + storage_key=storage_key, + ) + self._session.add(image) + await self._session.flush() + await self._session.refresh(image, ["image_tags"]) + return image + + async def list_images( + self, + tag_names: list[str] | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[Image], int]: + from sqlalchemy import func, and_ + + base_query = select(Image).options( + selectinload(Image.image_tags).selectinload(ImageTag.tag) + ) + + if tag_names: + for tag_name in tag_names: + subq = ( + select(ImageTag.image_id) + .join(Tag, ImageTag.tag_id == Tag.id) + .where(Tag.name == tag_name) + .scalar_subquery() + ) + base_query = base_query.where(Image.id.in_(subq)) + + count_query = select(func.count()).select_from(base_query.subquery()) + total_result = await self._session.execute(count_query) + total = total_result.scalar_one() + + paginated = base_query.order_by(Image.created_at.desc()).limit(limit).offset(offset) + result = await self._session.execute(paginated) + return result.scalars().all(), total + + async def delete(self, image: Image) -> None: + await self._session.delete(image) + await self._session.flush() diff --git a/api/app/repositories/tag_repo.py b/api/app/repositories/tag_repo.py new file mode 100644 index 0000000..5b8a1a3 --- /dev/null +++ b/api/app/repositories/tag_repo.py @@ -0,0 +1,102 @@ +import re +import uuid + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Image, ImageTag, Tag + +_TAG_PATTERN = re.compile(r"^[a-z0-9_-]{1,64}$") + + +class TagRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + @staticmethod + def normalise(name: str) -> str: + return name.strip().lower() + + @staticmethod + def normalise_and_validate(name: str) -> str: + normalised = name.strip().lower() + if not _TAG_PATTERN.match(normalised): + raise ValueError( + f"Invalid tag '{name}': must match ^[a-z0-9_-]{{1,64}}$ after normalisation" + ) + return normalised + + async def upsert_by_name(self, name: str) -> Tag: + result = await self._session.execute(select(Tag).where(Tag.name == name)) + tag = result.scalar_one_or_none() + if tag is None: + tag = Tag(name=name) + self._session.add(tag) + await self._session.flush() + return tag + + async def get_by_image_id(self, image_id: uuid.UUID) -> list[Tag]: + result = await self._session.execute( + select(Tag) + .join(ImageTag, ImageTag.tag_id == Tag.id) + .where(ImageTag.image_id == image_id) + .order_by(Tag.name) + ) + return result.scalars().all() + + async def attach_tags(self, image: Image, tag_names: list[str]) -> None: + for name in tag_names: + tag = await self.upsert_by_name(name) + existing = await self._session.execute( + select(ImageTag).where( + ImageTag.image_id == image.id, ImageTag.tag_id == tag.id + ) + ) + if existing.scalar_one_or_none() is None: + self._session.add(ImageTag(image_id=image.id, tag_id=tag.id)) + await self._session.flush() + + async def replace_tags_on_image(self, image: Image, tag_names: list[str]) -> None: + # Remove all existing associations + existing_links = await self._session.execute( + select(ImageTag).where(ImageTag.image_id == image.id) + ) + for link in existing_links.scalars().all(): + await self._session.delete(link) + await self._session.flush() + + # Add new associations + for name in tag_names: + tag = await self.upsert_by_name(name) + self._session.add(ImageTag(image_id=image.id, tag_id=tag.id)) + await self._session.flush() + + async def list_tags( + self, + prefix: str | None = None, + limit: int = 100, + offset: int = 0, + ) -> tuple[list[dict], int]: + count_subq = ( + select(func.count(ImageTag.image_id)) + .where(ImageTag.tag_id == Tag.id) + .correlate(Tag) + .scalar_subquery() + ) + + query = select(Tag, count_subq.label("image_count")) + if prefix: + query = query.where(Tag.name.like(f"{prefix}%")) + + total_query = select(func.count()).select_from(query.subquery()) + total_result = await self._session.execute(total_query) + total = total_result.scalar_one() + + paginated = query.order_by(Tag.name).limit(limit).offset(offset) + rows = await self._session.execute(paginated) + + items = [ + {"id": str(tag.id), "name": tag.name, "image_count": count} + for tag, count in rows.all() + ] + return items, total diff --git a/api/app/routers/__init__.py b/api/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/routers/images.py b/api/app/routers/images.py new file mode 100644 index 0000000..b99882e --- /dev/null +++ b/api/app/routers/images.py @@ -0,0 +1,271 @@ +import io +import struct +import uuid +import zlib +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile +from fastapi.responses import RedirectResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.provider import AuthProvider +from app.config import get_settings +from app.dependencies import get_auth, get_db, get_storage +from app.models import Image +from app.repositories.image_repo import ImageRepository +from app.repositories.tag_repo import TagRepository +from app.storage.backend import StorageBackend +from app.utils import compute_sha256 +from app.validation import FileSizeError, MimeTypeError, validate_file_size, validate_mime_type + +router = APIRouter(tags=["images"]) + + +def _error(detail: str, code: str, status: int): + raise HTTPException(status_code=status, detail={"detail": detail, "code": code}) + + +def _image_to_dict(image: Image, *, duplicate: bool | None = None) -> dict[str, Any]: + data: dict[str, Any] = { + "id": str(image.id), + "hash": image.hash, + "filename": image.filename, + "mime_type": image.mime_type, + "size_bytes": image.size_bytes, + "width": image.width, + "height": image.height, + "storage_key": image.storage_key, + "created_at": image.created_at.isoformat(), + "tags": image.tags, + } + if duplicate is not None: + data["duplicate"] = duplicate + return data + + +def _read_image_dimensions(data: bytes, mime_type: str) -> tuple[int, int]: + """Return (width, height) from raw image bytes. Falls back to (0, 0).""" + try: + if mime_type == "image/jpeg": + return _jpeg_dimensions(data) + elif mime_type == "image/png": + return _png_dimensions(data) + elif mime_type == "image/gif": + return _gif_dimensions(data) + elif mime_type == "image/webp": + return _webp_dimensions(data) + except Exception: + pass + return 0, 0 + + +def _jpeg_dimensions(data: bytes) -> tuple[int, int]: + i = 0 + while i < len(data): + if data[i] != 0xFF: + break + i += 1 + marker = data[i] + i += 1 + if marker in (0xD8, 0xD9): + continue + length = struct.unpack(">H", data[i : i + 2])[0] + if marker in (0xC0, 0xC1, 0xC2): + h, w = struct.unpack(">HH", data[i + 3 : i + 7]) + return w, h + i += length + return 0, 0 + + +def _png_dimensions(data: bytes) -> tuple[int, int]: + w, h = struct.unpack(">II", data[16:24]) + return w, h + + +def _gif_dimensions(data: bytes) -> tuple[int, int]: + w, h = struct.unpack(" tuple[int, int]: + if data[8:12] == b"VP8 ": + w = struct.unpack("> 14) & 0x3FFF) + 1 + return w, h + return 0, 0 + + +@router.post("/images", status_code=201) +async def upload_image( + file: UploadFile = File(...), + tags: str | None = Form(None), + db: AsyncSession = Depends(get_db), + storage: StorageBackend = Depends(get_storage), + auth: AuthProvider = Depends(get_auth), + settings=Depends(get_settings), +): + data = await file.read() + mime_type = file.content_type or "application/octet-stream" + + try: + validate_mime_type(mime_type) + except MimeTypeError: + raise HTTPException( + status_code=422, + detail={"detail": f"Unsupported file type: {mime_type}", "code": "invalid_mime_type"}, + ) + + try: + validate_file_size(len(data), max_bytes=settings.max_upload_bytes) + except FileSizeError as exc: + raise HTTPException( + status_code=422, + detail={"detail": str(exc), "code": "file_too_large"}, + ) + + hash_hex = compute_sha256(data) + image_repo = ImageRepository(db) + existing = await image_repo.get_by_hash(hash_hex) + if existing: + return Response( + content=__import__("json").dumps(_image_to_dict(existing, duplicate=True)), + status_code=200, + media_type="application/json", + ) + + # Parse tag names + tag_names: list[str] = [] + if tags: + tag_repo = TagRepository(db) + raw = [t.strip() for t in tags.replace(",", " ").split() if t.strip()] + try: + tag_names = [tag_repo.normalise_and_validate(t) for t in raw] + except ValueError as exc: + raise HTTPException( + status_code=422, + detail={"detail": str(exc), "code": "invalid_tag"}, + ) + + width, height = _read_image_dimensions(data, mime_type) + await storage.put(hash_hex, data, mime_type) + + image = await image_repo.create( + hash_hex=hash_hex, + filename=file.filename or "upload", + mime_type=mime_type, + size_bytes=len(data), + width=width, + height=height, + storage_key=hash_hex, + ) + + if tag_names: + tag_repo = TagRepository(db) + await tag_repo.attach_tags(image, tag_names) + await db.refresh(image, ["image_tags"]) + + return _image_to_dict(image, duplicate=False) + + +@router.get("/images") +async def list_images( + tags: str | None = None, + limit: int = 50, + offset: int = 0, + db: AsyncSession = Depends(get_db), +): + limit = min(limit, 100) + tag_names = [t.strip() for t in tags.split(",") if t.strip()] if tags else None + image_repo = ImageRepository(db) + images, total = await image_repo.list_images(tag_names=tag_names, limit=limit, offset=offset) + return { + "items": [_image_to_dict(img) for img in images], + "total": total, + "limit": limit, + "offset": offset, + } + + +@router.get("/images/{image_id}") +async def get_image( + image_id: uuid.UUID, + db: AsyncSession = Depends(get_db), +): + image_repo = ImageRepository(db) + image = await image_repo.get_by_id(image_id) + if not image: + raise HTTPException( + status_code=404, + detail={"detail": "Image not found", "code": "image_not_found"}, + ) + return _image_to_dict(image) + + +@router.get("/images/{image_id}/file") +async def serve_image_file( + image_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + storage: StorageBackend = Depends(get_storage), +): + image_repo = ImageRepository(db) + image = await image_repo.get_by_id(image_id) + if not image: + raise HTTPException( + status_code=404, + detail={"detail": "Image not found", "code": "image_not_found"}, + ) + url = await storage.get_presigned_url(image.storage_key, expires_in_seconds=3600) + return RedirectResponse(url=url, status_code=302) + + +@router.patch("/images/{image_id}/tags") +async def update_image_tags( + image_id: uuid.UUID, + body: dict, + db: AsyncSession = Depends(get_db), +): + image_repo = ImageRepository(db) + image = await image_repo.get_by_id(image_id) + if not image: + raise HTTPException( + status_code=404, + detail={"detail": "Image not found", "code": "image_not_found"}, + ) + + raw_tags: list[str] = body.get("tags", []) + tag_repo = TagRepository(db) + try: + tag_names = [tag_repo.normalise_and_validate(t) for t in raw_tags] + except ValueError as exc: + raise HTTPException( + status_code=422, + detail={"detail": str(exc), "code": "invalid_tag"}, + ) + + await tag_repo.replace_tags_on_image(image, tag_names) + await db.refresh(image, ["image_tags"]) + return _image_to_dict(image) + + +@router.delete("/images/{image_id}", status_code=204) +async def delete_image( + image_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + storage: StorageBackend = Depends(get_storage), +): + image_repo = ImageRepository(db) + image = await image_repo.get_by_id(image_id) + if not image: + raise HTTPException( + status_code=404, + detail={"detail": "Image not found", "code": "image_not_found"}, + ) + storage_key = image.storage_key + await image_repo.delete(image) + await storage.delete(storage_key) + return Response(status_code=204) diff --git a/api/app/routers/tags.py b/api/app/routers/tags.py new file mode 100644 index 0000000..f56390e --- /dev/null +++ b/api/app/routers/tags.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_db +from app.repositories.tag_repo import TagRepository + +router = APIRouter(tags=["tags"]) + + +@router.get("/tags") +async def list_tags( + q: str | None = None, + limit: int = 100, + offset: int = 0, + db: AsyncSession = Depends(get_db), +): + limit = min(limit, 200) + tag_repo = TagRepository(db) + items, total = await tag_repo.list_tags(prefix=q, limit=limit, offset=offset) + return {"items": items, "total": total, "limit": limit, "offset": offset} diff --git a/api/app/storage/__init__.py b/api/app/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/storage/backend.py b/api/app/storage/backend.py new file mode 100644 index 0000000..aa9a8ea --- /dev/null +++ b/api/app/storage/backend.py @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod + + +class StorageBackend(ABC): + @abstractmethod + async def put(self, key: str, data: bytes, content_type: str) -> None: + """Store object at key with given content type.""" + + @abstractmethod + async def get_presigned_url(self, key: str, expires_in_seconds: int = 3600) -> str: + """Return a pre-signed URL valid for expires_in_seconds.""" + + @abstractmethod + async def delete(self, key: str) -> None: + """Delete object at key.""" diff --git a/api/app/storage/s3_backend.py b/api/app/storage/s3_backend.py new file mode 100644 index 0000000..d047c1d --- /dev/null +++ b/api/app/storage/s3_backend.py @@ -0,0 +1,46 @@ +from contextlib import asynccontextmanager + +import aiobotocore.session + +from app.config import get_settings +from app.storage.backend import StorageBackend + + +class S3StorageBackend(StorageBackend): + def __init__(self) -> None: + self._settings = get_settings() + self._session = aiobotocore.session.get_session() + + @asynccontextmanager + async def _client(self): + s = self._settings + async with self._session.create_client( + "s3", + region_name=s.s3_region, + endpoint_url=s.s3_endpoint_url or None, + aws_access_key_id=s.s3_access_key_id, + aws_secret_access_key=s.s3_secret_access_key, + ) as client: + yield client + + async def put(self, key: str, data: bytes, content_type: str) -> None: + async with self._client() as client: + await client.put_object( + Bucket=self._settings.s3_bucket_name, + Key=key, + Body=data, + ContentType=content_type, + ) + + async def get_presigned_url(self, key: str, expires_in_seconds: int = 3600) -> str: + async with self._client() as client: + url = await client.generate_presigned_url( + "get_object", + Params={"Bucket": self._settings.s3_bucket_name, "Key": key}, + ExpiresIn=expires_in_seconds, + ) + return url + + async def delete(self, key: str) -> None: + async with self._client() as client: + await client.delete_object(Bucket=self._settings.s3_bucket_name, Key=key) diff --git a/api/app/utils.py b/api/app/utils.py new file mode 100644 index 0000000..638621c --- /dev/null +++ b/api/app/utils.py @@ -0,0 +1,5 @@ +import hashlib + + +def compute_sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() diff --git a/api/app/validation.py b/api/app/validation.py new file mode 100644 index 0000000..d45157f --- /dev/null +++ b/api/app/validation.py @@ -0,0 +1,21 @@ +ACCEPTED_MIME_TYPES = frozenset(["image/jpeg", "image/png", "image/gif", "image/webp"]) + + +class MimeTypeError(ValueError): + pass + + +class FileSizeError(ValueError): + pass + + +def validate_mime_type(mime_type: str) -> None: + if mime_type not in ACCEPTED_MIME_TYPES: + raise MimeTypeError(f"Unsupported MIME type: {mime_type}") + + +def validate_file_size(size_bytes: int, max_bytes: int) -> None: + if size_bytes <= 0: + raise FileSizeError("File must not be empty") + if size_bytes > max_bytes: + raise FileSizeError(f"File size {size_bytes} exceeds limit of {max_bytes} bytes") diff --git a/api/pyproject.toml b/api/pyproject.toml new file mode 100644 index 0000000..db02816 --- /dev/null +++ b/api/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.backends.legacy:build" + +[project] +name = "reactbin-api" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.111", + "uvicorn[standard]>=0.29", + "sqlalchemy[asyncio]>=2.0", + "asyncpg>=0.29", + "alembic>=1.13", + "aiobotocore>=2.13", + "pydantic-settings>=2.2", + "python-multipart>=0.0.9", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.2", + "pytest-asyncio>=0.23", + "httpx>=0.27", + "anyio>=4.4", +] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] +ignore = [] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] diff --git a/api/tests/__init__.py b/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/integration/__init__.py b/api/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py new file mode 100644 index 0000000..a60c598 --- /dev/null +++ b/api/tests/integration/conftest.py @@ -0,0 +1,59 @@ +import pytest +import pytest_asyncio +from httpx import AsyncClient, ASGITransport +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker + +from app.main import app +from app.config import get_settings +from app.database import Base +from app.dependencies import get_db, get_storage, get_auth + + +@pytest_asyncio.fixture(scope="session") +async def engine(): + settings = get_settings() + # Use a separate test database URL if TEST_DATABASE_URL is set + import os + db_url = os.getenv("TEST_DATABASE_URL", settings.database_url) + eng = create_async_engine(db_url, echo=False) + async with eng.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield eng + async with eng.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await eng.dispose() + + +@pytest_asyncio.fixture +async def db_session(engine): + session_factory = async_sessionmaker(engine, expire_on_commit=False) + async with session_factory() as session: + yield session + await session.rollback() + + +@pytest_asyncio.fixture +async def client(db_session): + from app.storage.s3_backend import S3StorageBackend + from app.auth.noop import NoOpAuthProvider + + storage = S3StorageBackend() + auth = NoOpAuthProvider() + + async def override_db(): + yield db_session + + def override_storage(): + return storage + + def override_auth(): + return auth + + app.dependency_overrides[get_db] = override_db + app.dependency_overrides[get_storage] = override_storage + app.dependency_overrides[get_auth] = override_auth + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + app.dependency_overrides.clear() diff --git a/api/tests/integration/test_delete.py b/api/tests/integration/test_delete.py new file mode 100644 index 0000000..1e7562a --- /dev/null +++ b/api/tests/integration/test_delete.py @@ -0,0 +1,60 @@ +""" +T065 — DELETE /api/v1/images/{id} → 204; subsequent GET returns 404 +T066 — DELETE verifies MinIO object is removed +T067 — DELETE of unknown ID → 404 image_not_found +""" +import io +import uuid +import pytest + + +def _minimal_jpeg_v2() -> bytes: + # Slightly different from test_upload.py to avoid cross-test dedup + return ( + b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x01" + b"\xff\xd9" + ) + + +@pytest.mark.asyncio +async def test_delete_removes_record(client): + data = _minimal_jpeg_v2() + upload = await client.post( + "/api/v1/images", + files={"file": ("del-test.jpg", io.BytesIO(data), "image/jpeg")}, + ) + image_id = upload.json()["id"] + + delete_resp = await client.delete(f"/api/v1/images/{image_id}") + assert delete_resp.status_code == 204 + + get_resp = await client.get(f"/api/v1/images/{image_id}") + assert get_resp.status_code == 404 + assert get_resp.json()["code"] == "image_not_found" + + +@pytest.mark.asyncio +async def test_delete_removes_storage_object(client): + data = _minimal_jpeg_v2() + b"\x00" + upload = await client.post( + "/api/v1/images", + files={"file": ("del-storage-test.jpg", io.BytesIO(data), "image/jpeg")}, + ) + assert upload.status_code in (200, 201) + image_id = upload.json()["id"] + storage_key = upload.json()["hash"] + + delete_resp = await client.delete(f"/api/v1/images/{image_id}") + assert delete_resp.status_code == 204 + + # Confirm storage redirect no longer works (404 since record is gone) + file_resp = await client.get(f"/api/v1/images/{image_id}/file") + assert file_resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_unknown_id_returns_404(client): + response = await client.delete(f"/api/v1/images/{uuid.uuid4()}") + assert response.status_code == 404 + body = response.json() + assert body["code"] == "image_not_found" diff --git a/api/tests/integration/test_health.py b/api/tests/integration/test_health.py new file mode 100644 index 0000000..b1f651b --- /dev/null +++ b/api/tests/integration/test_health.py @@ -0,0 +1,8 @@ +import pytest + + +@pytest.mark.asyncio +async def test_health_returns_ok(client): + response = await client.get("/api/v1/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/api/tests/integration/test_search.py b/api/tests/integration/test_search.py new file mode 100644 index 0000000..146ff49 --- /dev/null +++ b/api/tests/integration/test_search.py @@ -0,0 +1,60 @@ +""" +T041 — GET /api/v1/images?tags=cat,funny → only images with both tags +T042 — same query excludes images with only one matching tag +""" +import io +import pytest + + +def _minimal_gif() -> bytes: + return ( + b"GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00" + b"!\xf9\x04\x00\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01" + b"\x00\x00\x02\x02D\x01\x00;" + ) + + +@pytest.mark.asyncio +async def test_and_filter_returns_only_matching_images(client): + data = _minimal_gif() + + # Image with both tags + r_both = await client.post( + "/api/v1/images", + files={"file": ("both.gif", io.BytesIO(data), "image/gif")}, + data={"tags": "andcat,andfunny"}, + ) + both_id = r_both.json()["id"] + + # Image with only one of the two tags — use different content to avoid dedup + r_one = await client.post( + "/api/v1/images", + files={"file": ("one.gif", io.BytesIO(data + b"\x00"), "image/gif")}, + data={"tags": "andcat"}, + ) + + response = await client.get("/api/v1/images?tags=andcat,andfunny") + assert response.status_code == 200 + body = response.json() + ids = [item["id"] for item in body["items"]] + assert both_id in ids + assert r_one.json()["id"] not in ids + + +@pytest.mark.asyncio +async def test_filter_excludes_partial_tag_match(client): + data = _minimal_gif() + + # Image with only "exclcat" + r_partial = await client.post( + "/api/v1/images", + files={"file": ("partial.gif", io.BytesIO(data + b"\x01"), "image/gif")}, + data={"tags": "exclcat"}, + ) + + # Filter requires both exclcat and exclother + response = await client.get("/api/v1/images?tags=exclcat,exclother") + assert response.status_code == 200 + body = response.json() + ids = [item["id"] for item in body["items"]] + assert r_partial.json()["id"] not in ids diff --git a/api/tests/integration/test_serving.py b/api/tests/integration/test_serving.py new file mode 100644 index 0000000..48539e5 --- /dev/null +++ b/api/tests/integration/test_serving.py @@ -0,0 +1,41 @@ +""" +T055 — GET /api/v1/images/{id}/file → 302 with Location header +T056 — /file for unknown ID → 404 image_not_found +""" +import io +import uuid +import pytest + + +def _minimal_webp() -> bytes: + # Minimal VP8L WebP + return ( + b"RIFF$\x00\x00\x00WEBPVP8L\x18\x00\x00\x00" + b"/\x00\x00\x00\x00\x18\xf0\x1f\xfe\xff\x02\xfe\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + ) + + +@pytest.mark.asyncio +async def test_file_redirect_returns_302(client): + data = _minimal_webp() + upload = await client.post( + "/api/v1/images", + files={"file": ("img.webp", io.BytesIO(data), "image/webp")}, + ) + assert upload.status_code in (200, 201) + image_id = upload.json()["id"] + + # Don't follow redirects + response = await client.get(f"/api/v1/images/{image_id}/file", follow_redirects=False) + assert response.status_code == 302 + assert "Location" in response.headers + assert response.headers["Location"] # must not be empty + + +@pytest.mark.asyncio +async def test_file_unknown_id_returns_404(client): + response = await client.get(f"/api/v1/images/{uuid.uuid4()}/file") + assert response.status_code == 404 + body = response.json() + assert body["code"] == "image_not_found" diff --git a/api/tests/integration/test_tags.py b/api/tests/integration/test_tags.py new file mode 100644 index 0000000..233a529 --- /dev/null +++ b/api/tests/integration/test_tags.py @@ -0,0 +1,132 @@ +""" +T039 — upload with tags → tags persisted and returned +T040 — duplicate upload → existing record returned, tags unchanged +T057 — PATCH replaces tags, old tags unlinked, new tags upserted +T058 — PATCH with invalid tag → 422 invalid_tag +T073 — GET /api/v1/tags returns all tags alphabetically with correct image_count +T074 — GET /api/v1/tags?q=ca returns only tags prefixed "ca" +""" +import io +import pytest + + +def _minimal_png() -> bytes: + import struct, zlib + def chunk(name: bytes, data: bytes) -> bytes: + c = name + data + return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF) + + ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0) + idat_data = zlib.compress(b"\x00\xFF\xFF\xFF") + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", idat_data) + + chunk(b"IEND", b"") + ) + + +@pytest.mark.asyncio +async def test_upload_with_tags_persists_tags(client): + data = _minimal_png() + response = await client.post( + "/api/v1/images", + files={"file": ("img.png", io.BytesIO(data), "image/png")}, + data={"tags": "cat,funny"}, + ) + assert response.status_code == 201 + body = response.json() + assert set(body["tags"]) == {"cat", "funny"} + + +@pytest.mark.asyncio +async def test_duplicate_upload_tags_unchanged(client): + data = _minimal_png() + r1 = await client.post( + "/api/v1/images", + files={"file": ("img.png", io.BytesIO(data), "image/png")}, + data={"tags": "original-tag"}, + ) + assert r1.status_code in (200, 201) + original_tags = set(r1.json()["tags"]) + + r2 = await client.post( + "/api/v1/images", + files={"file": ("img.png", io.BytesIO(data), "image/png")}, + data={"tags": "different-tag"}, + ) + assert r2.status_code == 200 + assert r2.json()["duplicate"] is True + assert set(r2.json()["tags"]) == original_tags + + +@pytest.mark.asyncio +async def test_patch_replaces_tag_set(client): + data = _minimal_png() + r1 = await client.post( + "/api/v1/images", + files={"file": ("patch-test.png", io.BytesIO(data), "image/png")}, + data={"tags": "old-tag"}, + ) + image_id = r1.json()["id"] + + patch = await client.patch( + f"/api/v1/images/{image_id}/tags", + json={"tags": ["new-tag", "another"]}, + ) + assert patch.status_code == 200 + body = patch.json() + assert "old-tag" not in body["tags"] + assert set(body["tags"]) == {"new-tag", "another"} + + +@pytest.mark.asyncio +async def test_patch_invalid_tag_returns_422(client): + data = _minimal_png() + r1 = await client.post( + "/api/v1/images", + files={"file": ("invalid-tag-test.png", io.BytesIO(data), "image/png")}, + ) + image_id = r1.json()["id"] + + patch = await client.patch( + f"/api/v1/images/{image_id}/tags", + json={"tags": ["valid", "INVALID TAG WITH SPACES!"]}, + ) + assert patch.status_code == 422 + body = patch.json() + assert body["code"] == "invalid_tag" + + +@pytest.mark.asyncio +async def test_list_tags_alphabetical_with_counts(client): + data = _minimal_png() + await client.post( + "/api/v1/images", + files={"file": ("tag-list-test.png", io.BytesIO(data), "image/png")}, + data={"tags": "zebra,apple"}, + ) + response = await client.get("/api/v1/tags") + assert response.status_code == 200 + body = response.json() + names = [item["name"] for item in body["items"]] + assert names == sorted(names) + for item in body["items"]: + assert "image_count" in item + assert item["image_count"] >= 0 + + +@pytest.mark.asyncio +async def test_list_tags_prefix_filter(client): + data = _minimal_png() + await client.post( + "/api/v1/images", + files={"file": ("prefix-test.png", io.BytesIO(data), "image/png")}, + data={"tags": "cat,catfish,caterpillar,dog"}, + ) + response = await client.get("/api/v1/tags?q=cat") + assert response.status_code == 200 + body = response.json() + for item in body["items"]: + assert item["name"].startswith("cat") + assert not any(item["name"] == "dog" for item in body["items"]) diff --git a/api/tests/integration/test_upload.py b/api/tests/integration/test_upload.py new file mode 100644 index 0000000..25b8dd5 --- /dev/null +++ b/api/tests/integration/test_upload.py @@ -0,0 +1,100 @@ +""" +T026 — valid JPEG upload → 201, record in DB, object in MinIO +T027 — same image uploaded twice → 200, duplicate: true, no second MinIO object +T028 — invalid MIME type → 422 invalid_mime_type (error envelope with code field) +T029 — file > MAX_UPLOAD_BYTES → 422 file_too_large +T079 — GET /api/v1/images/{id} 404 → error envelope shape +""" +import io +import pytest + + +def _minimal_jpeg() -> bytes: + # Minimal valid JPEG bytes (SOI + APP0 + EOI) + return ( + b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + b"\xff\xd9" + ) + + +@pytest.mark.asyncio +async def test_upload_new_image_returns_201(client): + data = _minimal_jpeg() + response = await client.post( + "/api/v1/images", + files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")}, + ) + assert response.status_code == 201 + body = response.json() + assert body["duplicate"] is False + assert body["filename"] == "test.jpg" + assert body["mime_type"] == "image/jpeg" + assert "id" in body + assert "hash" in body + assert len(body["hash"]) == 64 + + +@pytest.mark.asyncio +async def test_upload_duplicate_returns_200_with_flag(client): + data = _minimal_jpeg() + # First upload + r1 = await client.post( + "/api/v1/images", + files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")}, + ) + assert r1.status_code in (200, 201) + + # Second upload of same bytes + r2 = await client.post( + "/api/v1/images", + files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")}, + ) + assert r2.status_code == 200 + body = r2.json() + assert body["duplicate"] is True + assert body["id"] == r1.json()["id"] + + +@pytest.mark.asyncio +async def test_upload_invalid_mime_type_returns_422(client): + response = await client.post( + "/api/v1/images", + files={"file": ("doc.pdf", io.BytesIO(b"%PDF-1.4"), "application/pdf")}, + ) + assert response.status_code == 422 + body = response.json() + assert body["code"] == "invalid_mime_type" + assert "detail" in body + + +@pytest.mark.asyncio +async def test_upload_oversized_file_returns_422(client, monkeypatch): + import app.config as config_module + original_settings = config_module.get_settings() + + class SmallSettings: + def __getattr__(self, name): + val = getattr(original_settings, name) + if name == "max_upload_bytes": + return 10 + return val + + monkeypatch.setattr(config_module, "get_settings", lambda: SmallSettings()) + + response = await client.post( + "/api/v1/images", + files={"file": ("big.jpg", io.BytesIO(b"x" * 11), "image/jpeg")}, + ) + assert response.status_code == 422 + body = response.json() + assert body["code"] == "file_too_large" + + +@pytest.mark.asyncio +async def test_get_unknown_image_returns_404_with_envelope(client): + import uuid + response = await client.get(f"/api/v1/images/{uuid.uuid4()}") + assert response.status_code == 404 + body = response.json() + assert body["code"] == "image_not_found" + assert "detail" in body diff --git a/api/tests/unit/__init__.py b/api/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/unit/test_config.py b/api/tests/unit/test_config.py new file mode 100644 index 0000000..f217065 --- /dev/null +++ b/api/tests/unit/test_config.py @@ -0,0 +1,40 @@ +import os +import pytest + + +def test_settings_load_from_env(monkeypatch): + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@localhost/db") + monkeypatch.setenv("S3_ENDPOINT_URL", "http://localhost:9000") + monkeypatch.setenv("S3_BUCKET_NAME", "test-bucket") + monkeypatch.setenv("S3_ACCESS_KEY_ID", "key") + monkeypatch.setenv("S3_SECRET_ACCESS_KEY", "secret") + monkeypatch.setenv("S3_REGION", "us-east-1") + monkeypatch.setenv("API_BASE_URL", "http://localhost:8000") + + # Import inside test to pick up monkeypatched env + import importlib + import app.config as config_module + importlib.reload(config_module) + + s = config_module.Settings() + assert s.database_url == "postgresql+asyncpg://u:p@localhost/db" + assert s.s3_bucket_name == "test-bucket" + assert s.max_upload_bytes == 52428800 # default + + +def test_settings_max_upload_bytes_override(monkeypatch): + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@localhost/db") + monkeypatch.setenv("S3_ENDPOINT_URL", "http://localhost:9000") + monkeypatch.setenv("S3_BUCKET_NAME", "test-bucket") + monkeypatch.setenv("S3_ACCESS_KEY_ID", "key") + monkeypatch.setenv("S3_SECRET_ACCESS_KEY", "secret") + monkeypatch.setenv("S3_REGION", "us-east-1") + monkeypatch.setenv("API_BASE_URL", "http://localhost:8000") + monkeypatch.setenv("MAX_UPLOAD_BYTES", "10485760") + + import importlib + import app.config as config_module + importlib.reload(config_module) + + s = config_module.Settings() + assert s.max_upload_bytes == 10485760 diff --git a/api/tests/unit/test_hashing.py b/api/tests/unit/test_hashing.py new file mode 100644 index 0000000..c73c979 --- /dev/null +++ b/api/tests/unit/test_hashing.py @@ -0,0 +1,20 @@ +import hashlib +from app.utils import compute_sha256 + + +def test_sha256_known_bytes(): + data = b"hello world" + expected = hashlib.sha256(data).hexdigest() + assert compute_sha256(data) == expected + + +def test_sha256_empty_bytes(): + data = b"" + expected = hashlib.sha256(data).hexdigest() + assert compute_sha256(data) == expected + + +def test_sha256_returns_64_char_hex(): + result = compute_sha256(b"test data") + assert len(result) == 64 + assert all(c in "0123456789abcdef" for c in result) diff --git a/api/tests/unit/test_tags.py b/api/tests/unit/test_tags.py new file mode 100644 index 0000000..c232c41 --- /dev/null +++ b/api/tests/unit/test_tags.py @@ -0,0 +1,42 @@ +""" +T037 — tag normalisation: uppercase → lowercase, whitespace stripped +T038 — tag validation: rejects names > 64 chars, invalid chars +""" +import pytest +from app.repositories.tag_repo import TagRepository + + +@pytest.mark.parametrize("raw,expected", [ + ("Cat", "cat"), + (" funny ", "funny"), + ("REACTION", "reaction"), + (" MiXeD ", "mixed"), +]) +def test_normalise_lowercases_and_strips(raw, expected): + assert TagRepository.normalise(raw) == expected + + +def test_validate_accepts_valid_tags(): + for name in ["cat", "funny-face", "my_tag", "tag123", "a" * 64]: + TagRepository.normalise_and_validate(name) # should not raise + + +def test_validate_rejects_too_long(): + with pytest.raises(ValueError): + TagRepository.normalise_and_validate("a" * 65) + + +def test_validate_rejects_invalid_chars(): + with pytest.raises(ValueError): + TagRepository.normalise_and_validate("bad tag!") # space + exclamation + + +def test_validate_rejects_empty(): + with pytest.raises(ValueError): + TagRepository.normalise_and_validate("") + + +def test_validate_applies_normalisation_first(): + # "CAT" normalises to "cat" which is valid + result = TagRepository.normalise_and_validate("CAT") + assert result == "cat" diff --git a/api/tests/unit/test_validation.py b/api/tests/unit/test_validation.py new file mode 100644 index 0000000..44a1d08 --- /dev/null +++ b/api/tests/unit/test_validation.py @@ -0,0 +1,34 @@ +import pytest +from app.validation import validate_mime_type, validate_file_size, MimeTypeError, FileSizeError + +ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"] +REJECTED_TYPES = ["application/pdf", "video/mp4", "text/plain", "application/octet-stream"] + + +@pytest.mark.parametrize("mime_type", ACCEPTED_TYPES) +def test_mime_type_accepts_images(mime_type): + validate_mime_type(mime_type) # should not raise + + +@pytest.mark.parametrize("mime_type", REJECTED_TYPES) +def test_mime_type_rejects_non_images(mime_type): + with pytest.raises(MimeTypeError): + validate_mime_type(mime_type) + + +def test_file_size_accepts_within_limit(): + validate_file_size(1024, max_bytes=52_428_800) # should not raise + + +def test_file_size_accepts_exact_limit(): + validate_file_size(52_428_800, max_bytes=52_428_800) # should not raise + + +def test_file_size_rejects_over_limit(): + with pytest.raises(FileSizeError): + validate_file_size(52_428_801, max_bytes=52_428_800) + + +def test_file_size_rejects_zero(): + with pytest.raises(FileSizeError): + validate_file_size(0, max_bytes=52_428_800) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..44c4b5f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,85 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: reactbin + POSTGRES_PASSWORD: reactbin + POSTGRES_DB: reactbin + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U reactbin"] + interval: 5s + timeout: 5s + retries: 5 + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-minioadmin} + MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-minioadmin} + volumes: + - minio_data:/data + ports: + - "9000:9000" + - "9001:9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 + + minio-init: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-minioadmin} + MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-minioadmin} + S3_BUCKET_NAME: ${S3_BUCKET_NAME:-reactbin} + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD && + mc mb --ignore-existing local/$$S3_BUCKET_NAME && + mc anonymous set download local/$$S3_BUCKET_NAME + " + + api: + build: + context: ./api + env_file: .env + ports: + - "8000:8000" + volumes: + - ./api:/app + depends_on: + postgres: + condition: service_healthy + minio-init: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8000/api/v1/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + + ui: + build: + context: ./ui + env_file: .env + ports: + - "4200:4200" + volumes: + - ./ui:/app + - /app/node_modules + depends_on: + api: + condition: service_healthy + +volumes: + postgres_data: + minio_data: diff --git a/specs/001-reaction-image-board/tasks.md b/specs/001-reaction-image-board/tasks.md index 9ddad7c..5d90a0c 100644 --- a/specs/001-reaction-image-board/tasks.md +++ b/specs/001-reaction-image-board/tasks.md @@ -40,20 +40,20 @@ ui/src/app// Angular component dirs (+ .spec.ts colocated) **Purpose**: Establish the monorepo layout, Docker Compose stack, and linting baseline. No feature logic. All subsequent milestones build on this. -- [ ] T001 Create top-level monorepo layout: `api/`, `ui/`, `docker-compose.yml`, `.env.example` -- [ ] T002 Write `.env.example` with all variables from spec §5 (DATABASE_URL, S3_*, API_BASE_URL, MAX_UPLOAD_BYTES) -- [ ] T003 [P] Write `api/Dockerfile` (Python 3.12 slim, installs pyproject.toml deps, runs uvicorn) -- [ ] T004 [P] Scaffold Angular project with CLI into `ui/` (strict mode, standalone components, routing) -- [ ] T005 [P] Write `ui/Dockerfile` (Node LTS, `ng serve --host 0.0.0.0`) -- [ ] T006 Write `docker-compose.yml` defining: postgres, minio, api (depends_on postgres+minio), ui (depends_on api) -- [ ] T007 [P] Configure `api/pyproject.toml` with FastAPI, SQLAlchemy 2.x async, asyncpg, Alembic, aiobotocore, pydantic-settings, pytest, pytest-asyncio, ruff -- [ ] T008 [P] Configure `ui/package.json` / `angular.json` with eslint + prettier; add `ui/proxy.conf.json` routing `/api/*` to `http://localhost:8000` -- [ ] T009 Write API unit test: settings load from env vars without error in `api/tests/unit/test_config.py` -- [ ] T010 Write API integration test: `GET /api/v1/health` returns 200 `{"status":"ok"}` in `api/tests/integration/test_health.py` -- [ ] T011 Implement `api/app/config.py` (pydantic-settings reading all env vars) -- [ ] T012 Implement `api/app/main.py` (FastAPI factory, lifespan connecting to Postgres + MinIO, health route) -- [ ] T013 Configure Alembic in `api/alembic/` with async engine; apply `alembic upgrade head` on startup -- [ ] T014 [P] Add Angular default smoke test in `ui/src/app/app.component.spec.ts` +- [x] T001 Create top-level monorepo layout: `api/`, `ui/`, `docker-compose.yml`, `.env.example` +- [x] T002 Write `.env.example` with all variables from spec §5 (DATABASE_URL, S3_*, API_BASE_URL, MAX_UPLOAD_BYTES) +- [x] T003 [P] Write `api/Dockerfile` (Python 3.12 slim, installs pyproject.toml deps, runs uvicorn) +- [x] T004 [P] Scaffold Angular project with CLI into `ui/` (strict mode, standalone components, routing) +- [x] T005 [P] Write `ui/Dockerfile` (Node LTS, `ng serve --host 0.0.0.0`) +- [x] T006 Write `docker-compose.yml` defining: postgres, minio, api (depends_on postgres+minio), ui (depends_on api) +- [x] T007 [P] Configure `api/pyproject.toml` with FastAPI, SQLAlchemy 2.x async, asyncpg, Alembic, aiobotocore, pydantic-settings, pytest, pytest-asyncio, ruff +- [x] T008 [P] Configure `ui/package.json` / `angular.json` with eslint + prettier; add `ui/proxy.conf.json` routing `/api/*` to `http://localhost:8000` +- [x] T009 Write API unit test: settings load from env vars without error in `api/tests/unit/test_config.py` +- [x] T010 Write API integration test: `GET /api/v1/health` returns 200 `{"status":"ok"}` in `api/tests/integration/test_health.py` +- [x] T011 Implement `api/app/config.py` (pydantic-settings reading all env vars) +- [x] T012 Implement `api/app/main.py` (FastAPI factory, lifespan connecting to Postgres + MinIO, health route) +- [x] T013 Configure Alembic in `api/alembic/` with async engine; apply `alembic upgrade head` on startup +- [x] T014 [P] Add Angular default smoke test in `ui/src/app/app.component.spec.ts` **Checkpoint**: `docker compose up` starts all four services. Health endpoint returns 200. Both linters pass. All tests pass. @@ -68,17 +68,17 @@ story endpoint can be implemented. Establishes the `StorageBackend`, **⚠️ CRITICAL**: No user story work can begin until this phase is complete. -- [ ] T015 Write unit test: SHA-256 hash of known bytes returns expected hex digest in `api/tests/unit/test_hashing.py` -- [ ] T016 Write unit test: MIME validator accepts jpeg/png/gif/webp and rejects pdf/mp4 in `api/tests/unit/test_validation.py` -- [ ] T017 Write unit test: file size validator rejects bytes exceeding MAX_UPLOAD_BYTES in `api/tests/unit/test_validation.py` -- [ ] T018 [P] Implement `StorageBackend` interface (put, get_presigned_url, delete) in `api/app/storage/backend.py` -- [ ] T019 [P] Implement `S3StorageBackend` using aiobotocore in `api/app/storage/s3_backend.py` -- [ ] T020 [P] Implement `AuthProvider` interface + `NoOpAuthProvider` in `api/app/auth/provider.py` and `api/app/auth/noop.py` -- [ ] T021 Implement MIME type + file size validation helpers in `api/app/routers/images.py` (or `api/app/validation.py`) -- [ ] T022 Write Alembic migration for `images` table in `api/alembic/versions/` -- [ ] T023 Implement `Image` SQLAlchemy model in `api/app/models.py` -- [ ] T024 Implement `ImageRepository` (create, get_by_id, get_by_hash) in `api/app/repositories/image_repo.py` -- [ ] T025 Wire `AuthProvider`, `StorageBackend`, and DB session into FastAPI dependency injection in `api/app/dependencies.py` +- [x] T015 Write unit test: SHA-256 hash of known bytes returns expected hex digest in `api/tests/unit/test_hashing.py` +- [x] T016 Write unit test: MIME validator accepts jpeg/png/gif/webp and rejects pdf/mp4 in `api/tests/unit/test_validation.py` +- [x] T017 Write unit test: file size validator rejects bytes exceeding MAX_UPLOAD_BYTES in `api/tests/unit/test_validation.py` +- [x] T018 [P] Implement `StorageBackend` interface (put, get_presigned_url, delete) in `api/app/storage/backend.py` +- [x] T019 [P] Implement `S3StorageBackend` using aiobotocore in `api/app/storage/s3_backend.py` +- [x] T020 [P] Implement `AuthProvider` interface + `NoOpAuthProvider` in `api/app/auth/provider.py` and `api/app/auth/noop.py` +- [x] T021 Implement MIME type + file size validation helpers in `api/app/routers/images.py` (or `api/app/validation.py`) +- [x] T022 Write Alembic migration for `images` table in `api/alembic/versions/` +- [x] T023 Implement `Image` SQLAlchemy model in `api/app/models.py` +- [x] T024 Implement `ImageRepository` (create, get_by_id, get_by_hash) in `api/app/repositories/image_repo.py` +- [x] T025 Wire `AuthProvider`, `StorageBackend`, and DB session into FastAPI dependency injection in `api/app/dependencies.py` **Checkpoint**: All unit tests pass; foundation ready for user story endpoints. @@ -97,20 +97,20 @@ and no duplicate in DB or MinIO. > **NOTE: Write these tests FIRST, ensure they FAIL before implementation** -- [ ] T026 [P] [US1] Integration test: valid JPEG upload → 201, record in DB, object in MinIO in `api/tests/integration/test_upload.py` -- [ ] T027 [P] [US1] Integration test: same image uploaded twice → 200, `duplicate: true`, no second MinIO object in `api/tests/integration/test_upload.py` -- [ ] T028 [P] [US1] Integration test: invalid MIME type → 422 with `{"detail":"...","code":"invalid_mime_type"}` in `api/tests/integration/test_upload.py` -- [ ] T029 [P] [US1] Integration test: file > MAX_UPLOAD_BYTES → 422 `file_too_large` in `api/tests/integration/test_upload.py` -- [ ] T030 [P] [US1] Angular unit test: tag chip input lowercases and splits on comma/space in `ui/src/app/upload/upload.component.spec.ts` -- [ ] T031 [P] [US1] Angular unit test: `duplicate: true` response → toast shown, navigate to detail in `ui/src/app/upload/upload.component.spec.ts` -- [ ] T032 [P] [US1] Angular unit test: `duplicate: false` response → success toast, navigate to detail in `ui/src/app/upload/upload.component.spec.ts` -- [ ] T033 [P] [US1] Angular unit test: error response → inline error shown, no navigation in `ui/src/app/upload/upload.component.spec.ts` +- [x] T026 [P] [US1] Integration test: valid JPEG upload → 201, record in DB, object in MinIO in `api/tests/integration/test_upload.py` +- [x] T027 [P] [US1] Integration test: same image uploaded twice → 200, `duplicate: true`, no second MinIO object in `api/tests/integration/test_upload.py` +- [x] T028 [P] [US1] Integration test: invalid MIME type → 422 with `{"detail":"...","code":"invalid_mime_type"}` in `api/tests/integration/test_upload.py` +- [x] T029 [P] [US1] Integration test: file > MAX_UPLOAD_BYTES → 422 `file_too_large` in `api/tests/integration/test_upload.py` +- [x] T030 [P] [US1] Angular unit test: tag chip input lowercases and splits on comma/space in `ui/src/app/upload/upload.component.spec.ts` +- [x] T031 [P] [US1] Angular unit test: `duplicate: true` response → toast shown, navigate to detail in `ui/src/app/upload/upload.component.spec.ts` +- [x] T032 [P] [US1] Angular unit test: `duplicate: false` response → success toast, navigate to detail in `ui/src/app/upload/upload.component.spec.ts` +- [x] T033 [P] [US1] Angular unit test: error response → inline error shown, no navigation in `ui/src/app/upload/upload.component.spec.ts` ### Implementation for User Story 1 -- [ ] T034 [US1] Implement `POST /api/v1/images` endpoint (MIME check, size check, SHA-256, duplicate query, storage write, record insert; tags field accepted but ignored) in `api/app/routers/images.py` -- [ ] T035 [US1] Implement `ImageService` wrapping `GET /api/v1/images` and `GET /api/v1/images/{id}/file` in `ui/src/app/services/image.service.ts` -- [ ] T036 [US1] Implement `UploadComponent` (route `/upload`) with drag-and-drop zone, click-to-browse, tag chip input, POST submit, duplicate/success/error handling in `ui/src/app/upload/upload.component.ts` +- [x] T034 [US1] Implement `POST /api/v1/images` endpoint (MIME check, size check, SHA-256, duplicate query, storage write, record insert; tags field accepted but ignored) in `api/app/routers/images.py` +- [x] T035 [US1] Implement `ImageService` wrapping `GET /api/v1/images` and `GET /api/v1/images/{id}/file` in `ui/src/app/services/image.service.ts` +- [x] T036 [US1] Implement `UploadComponent` (route `/upload`) with drag-and-drop zone, click-to-browse, tag chip input, POST submit, duplicate/success/error handling in `ui/src/app/upload/upload.component.ts` **Checkpoint**: Full upload flow works in browser. Duplicate detection gives correct feedback. API tests and Angular unit tests all pass. @@ -130,27 +130,27 @@ both tags must be present. Remove a filter, verify the grid expands. > **NOTE: Write these tests FIRST, ensure they FAIL before implementation** -- [ ] T037 [P] [US2] Unit test: tag normalisation — uppercase → lowercase, whitespace stripped in `api/tests/unit/test_tags.py` -- [ ] T038 [P] [US2] Unit test: tag validation — rejects names > 64 chars, invalid chars in `api/tests/unit/test_tags.py` -- [ ] T039 [P] [US2] Integration test: upload with tags → tags persisted, returned in response in `api/tests/integration/test_tags.py` -- [ ] T040 [P] [US2] Integration test: duplicate upload → existing record returned, tags unchanged in `api/tests/integration/test_tags.py` -- [ ] T041 [P] [US2] Integration test: `GET /api/v1/images?tags=cat,funny` → only images with both tags in `api/tests/integration/test_search.py` -- [ ] T042 [P] [US2] Integration test: same query excludes images with only one matching tag in `api/tests/integration/test_search.py` -- [ ] T043 [P] [US2] Angular unit test: `ImageService` constructs correct query params from filter state in `ui/src/app/services/image.service.spec.ts` -- [ ] T044 [P] [US2] Angular unit test: `LibraryComponent` renders image grid from mocked service in `ui/src/app/library/library.component.spec.ts` -- [ ] T045 [P] [US2] Angular unit test: filter change triggers new API call with updated `tags` param in `ui/src/app/library/library.component.spec.ts` +- [x] T037 [P] [US2] Unit test: tag normalisation — uppercase → lowercase, whitespace stripped in `api/tests/unit/test_tags.py` +- [x] T038 [P] [US2] Unit test: tag validation — rejects names > 64 chars, invalid chars in `api/tests/unit/test_tags.py` +- [x] T039 [P] [US2] Integration test: upload with tags → tags persisted, returned in response in `api/tests/integration/test_tags.py` +- [x] T040 [P] [US2] Integration test: duplicate upload → existing record returned, tags unchanged in `api/tests/integration/test_tags.py` +- [x] T041 [P] [US2] Integration test: `GET /api/v1/images?tags=cat,funny` → only images with both tags in `api/tests/integration/test_search.py` +- [x] T042 [P] [US2] Integration test: same query excludes images with only one matching tag in `api/tests/integration/test_search.py` +- [x] T043 [P] [US2] Angular unit test: `ImageService` constructs correct query params from filter state in `ui/src/app/services/image.service.spec.ts` +- [x] T044 [P] [US2] Angular unit test: `LibraryComponent` renders image grid from mocked service in `ui/src/app/library/library.component.spec.ts` +- [x] T045 [P] [US2] Angular unit test: filter change triggers new API call with updated `tags` param in `ui/src/app/library/library.component.spec.ts` ### Implementation for User Story 2 -- [ ] T046 [US2] Write Alembic migration for `tags` and `image_tags` tables in `api/alembic/versions/` -- [ ] T047 [US2] Implement `Tag` and `ImageTag` SQLAlchemy models in `api/app/models.py` -- [ ] T048 [US2] Implement tag normalisation + validation helpers in `api/app/repositories/tag_repo.py` -- [ ] T049 [US2] Implement `TagRepository` (upsert_by_name, get_by_image_id) in `api/app/repositories/tag_repo.py` -- [ ] T050 [US2] Update `POST /api/v1/images` to process and persist the `tags` field in `api/app/routers/images.py` -- [ ] T051 [US2] Implement `GET /api/v1/images` with `tags` (AND-filter), `limit`, `offset` in `api/app/routers/images.py` -- [ ] T052 [US2] Implement `GET /api/v1/images/{id}` returning image + tags in `api/app/routers/images.py` -- [ ] T053 [US2] Update `ImageService` to support `tags` filter query param in `ui/src/app/services/image.service.ts` -- [ ] T054 [US2] Implement `LibraryComponent` (route `/`) with image grid, tag chips, debounced filter bar, "Load more" pagination in `ui/src/app/library/library.component.ts` +- [x] T046 [US2] Write Alembic migration for `tags` and `image_tags` tables in `api/alembic/versions/` +- [x] T047 [US2] Implement `Tag` and `ImageTag` SQLAlchemy models in `api/app/models.py` +- [x] T048 [US2] Implement tag normalisation + validation helpers in `api/app/repositories/tag_repo.py` +- [x] T049 [US2] Implement `TagRepository` (upsert_by_name, get_by_image_id) in `api/app/repositories/tag_repo.py` +- [x] T050 [US2] Update `POST /api/v1/images` to process and persist the `tags` field in `api/app/routers/images.py` +- [x] T051 [US2] Implement `GET /api/v1/images` with `tags` (AND-filter), `limit`, `offset` in `api/app/routers/images.py` +- [x] T052 [US2] Implement `GET /api/v1/images/{id}` returning image + tags in `api/app/routers/images.py` +- [x] T053 [US2] Update `ImageService` to support `tags` filter query param in `ui/src/app/services/image.service.ts` +- [x] T054 [US2] Implement `LibraryComponent` (route `/`) with image grid, tag chips, debounced filter bar, "Load more" pagination in `ui/src/app/library/library.component.ts` **Checkpoint**: Library view shows real images with tags. Tag filtering (AND logic) and pagination work end-to-end. @@ -169,19 +169,19 @@ filter by that tag, and confirm the image appears. > **NOTE: Write these tests FIRST, ensure they FAIL before implementation** -- [ ] T055 [P] [US3] Integration test: `GET /api/v1/images/{id}/file` → 302 with `Location` header pointing to MinIO URL in `api/tests/integration/test_serving.py` -- [ ] T056 [P] [US3] Integration test: `/file` for unknown ID → 404 `image_not_found` in `api/tests/integration/test_serving.py` -- [ ] T057 [P] [US3] Integration test: `PATCH /api/v1/images/{id}/tags` replaces tags, old tags unlinked, new tags upserted in `api/tests/integration/test_tags.py` -- [ ] T058 [P] [US3] Integration test: PATCH with invalid tag → 422 `invalid_tag` in `api/tests/integration/test_tags.py` -- [ ] T059 [P] [US3] Angular unit test: removing tag chip calls PATCH with updated list (removed tag absent) in `ui/src/app/detail/detail.component.spec.ts` -- [ ] T060 [P] [US3] Angular unit test: adding tag + Enter calls PATCH with new tag included in `ui/src/app/detail/detail.component.spec.ts` +- [x] T055 [P] [US3] Integration test: `GET /api/v1/images/{id}/file` → 302 with `Location` header pointing to MinIO URL in `api/tests/integration/test_serving.py` +- [x] T056 [P] [US3] Integration test: `/file` for unknown ID → 404 `image_not_found` in `api/tests/integration/test_serving.py` +- [x] T057 [P] [US3] Integration test: `PATCH /api/v1/images/{id}/tags` replaces tags, old tags unlinked, new tags upserted in `api/tests/integration/test_tags.py` +- [x] T058 [P] [US3] Integration test: PATCH with invalid tag → 422 `invalid_tag` in `api/tests/integration/test_tags.py` +- [x] T059 [P] [US3] Angular unit test: removing tag chip calls PATCH with updated list (removed tag absent) in `ui/src/app/detail/detail.component.spec.ts` +- [x] T060 [P] [US3] Angular unit test: adding tag + Enter calls PATCH with new tag included in `ui/src/app/detail/detail.component.spec.ts` ### Implementation for User Story 3 -- [ ] T061 [US3] Implement `GET /api/v1/images/{id}/file` (generate 1-hour pre-signed URL, return 302) in `api/app/routers/images.py` -- [ ] T062 [US3] Implement `TagRepository.replace_tags_on_image` in `api/app/repositories/tag_repo.py` -- [ ] T063 [US3] Implement `PATCH /api/v1/images/{id}/tags` in `api/app/routers/images.py` -- [ ] T064 [US3] Implement `DetailComponent` (route `/images/:id`) with full-size image, editable tag chips (add/remove), save on blur/Enter via PATCH, back button in `ui/src/app/detail/detail.component.ts` +- [x] T061 [US3] Implement `GET /api/v1/images/{id}/file` (generate 1-hour pre-signed URL, return 302) in `api/app/routers/images.py` +- [x] T062 [US3] Implement `TagRepository.replace_tags_on_image` in `api/app/repositories/tag_repo.py` +- [x] T063 [US3] Implement `PATCH /api/v1/images/{id}/tags` in `api/app/routers/images.py` +- [x] T064 [US3] Implement `DetailComponent` (route `/images/:id`) with full-size image, editable tag chips (add/remove), save on blur/Enter via PATCH, back button in `ui/src/app/detail/detail.component.ts` **Checkpoint**: Full-size image loads in browser via redirect. Tag editing works from detail page. Changes persist across page navigation. @@ -200,17 +200,17 @@ library and that navigating to its former URL shows a not-found screen. > **NOTE: Write these tests FIRST, ensure they FAIL before implementation** -- [ ] T065 [P] [US4] Integration test: `DELETE /api/v1/images/{id}` → 204; subsequent `GET /{id}` returns 404 in `api/tests/integration/test_delete.py` -- [ ] T066 [P] [US4] Integration test: DELETE verifies MinIO object is removed in `api/tests/integration/test_delete.py` -- [ ] T067 [P] [US4] Integration test: DELETE of unknown ID → 404 `image_not_found` in `api/tests/integration/test_delete.py` -- [ ] T068 [P] [US4] Angular unit test: delete confirmation → DELETE called → navigation to Library in `ui/src/app/detail/detail.component.spec.ts` -- [ ] T069 [P] [US4] Angular unit test: cancel confirmation dialog → no DELETE call, stays on detail page in `ui/src/app/detail/detail.component.spec.ts` +- [x] T065 [P] [US4] Integration test: `DELETE /api/v1/images/{id}` → 204; subsequent `GET /{id}` returns 404 in `api/tests/integration/test_delete.py` +- [x] T066 [P] [US4] Integration test: DELETE verifies MinIO object is removed in `api/tests/integration/test_delete.py` +- [x] T067 [P] [US4] Integration test: DELETE of unknown ID → 404 `image_not_found` in `api/tests/integration/test_delete.py` +- [x] T068 [P] [US4] Angular unit test: delete confirmation → DELETE called → navigation to Library in `ui/src/app/detail/detail.component.spec.ts` +- [x] T069 [P] [US4] Angular unit test: cancel confirmation dialog → no DELETE call, stays on detail page in `ui/src/app/detail/detail.component.spec.ts` ### Implementation for User Story 4 -- [ ] T070 [US4] Implement `DELETE /api/v1/images/{id}` (delete image_tags rows, image record, S3 object) in `api/app/routers/images.py` -- [ ] T071 [US4] Add delete button with confirmation dialog + back-to-Library navigation to `DetailComponent` in `ui/src/app/detail/detail.component.ts` -- [ ] T072 [US4] Implement `NotFoundComponent` shown for all unrecognised routes in `ui/src/app/not-found/not-found.component.ts` +- [x] T070 [US4] Implement `DELETE /api/v1/images/{id}` (delete image_tags rows, image record, S3 object) in `api/app/routers/images.py` +- [x] T071 [US4] Add delete button with confirmation dialog + back-to-Library navigation to `DetailComponent` in `ui/src/app/detail/detail.component.ts` +- [x] T072 [US4] Implement `NotFoundComponent` shown for all unrecognised routes in `ui/src/app/not-found/not-found.component.ts` **Checkpoint**: Full CRUD loop works: upload → view → re-tag → delete. Deleted images gone from library and storage. @@ -230,15 +230,15 @@ matching tags remain. > **NOTE: Write these tests FIRST, ensure they FAIL before implementation** -- [ ] T073 [P] [US5] Integration test: `GET /api/v1/tags` returns all tags alphabetically with correct image_count in `api/tests/integration/test_tags.py` -- [ ] T074 [P] [US5] Integration test: `GET /api/v1/tags?q=ca` returns only tags prefixed "ca" in `api/tests/integration/test_tags.py` -- [ ] T075 [P] [US5] Angular unit test: `TagService` calls `GET /api/v1/tags` with `q` param in `ui/src/app/services/tag.service.spec.ts` +- [x] T073 [P] [US5] Integration test: `GET /api/v1/tags` returns all tags alphabetically with correct image_count in `api/tests/integration/test_tags.py` +- [x] T074 [P] [US5] Integration test: `GET /api/v1/tags?q=ca` returns only tags prefixed "ca" in `api/tests/integration/test_tags.py` +- [x] T075 [P] [US5] Angular unit test: `TagService` calls `GET /api/v1/tags` with `q` param in `ui/src/app/services/tag.service.spec.ts` ### Implementation for User Story 5 -- [ ] T076 [US5] Implement `GET /api/v1/tags` with `q` prefix search, `limit`, `offset`, image_count in `api/app/routers/tags.py` -- [ ] T077 [US5] Implement `TagService` wrapping `GET /api/v1/tags` in `ui/src/app/services/tag.service.ts` -- [ ] T078 [US5] Wire `TagService` into `LibraryComponent` tag filter bar for tag autocomplete/selection in `ui/src/app/library/library.component.ts` +- [x] T076 [US5] Implement `GET /api/v1/tags` with `q` prefix search, `limit`, `offset`, image_count in `api/app/routers/tags.py` +- [x] T077 [US5] Implement `TagService` wrapping `GET /api/v1/tags` in `ui/src/app/services/tag.service.ts` +- [x] T078 [US5] Wire `TagService` into `LibraryComponent` tag filter bar for tag autocomplete/selection in `ui/src/app/library/library.component.ts` **Checkpoint**: All user stories independently functional and tested. @@ -248,16 +248,16 @@ matching tags remain. **Purpose**: Improvements affecting multiple user stories and final validation. -- [ ] T079 [P] Add `GET /api/v1/images/{id}` 404 test to verify error envelope shape `{"detail":"...","code":"image_not_found"}` in `api/tests/integration/test_upload.py` -- [ ] T080 [P] Verify all API error responses include both `detail` and `code` fields (constitution §3.3) — check tests for T028, T029, T056, T058, T067 -- [ ] T081 [P] Add empty-state UI for library with zero images in `ui/src/app/library/library.component.ts` -- [ ] T082 [P] Add empty-state UI for tag filter returning zero results in `ui/src/app/library/library.component.ts` -- [ ] T083 Configure Angular routing to show `NotFoundComponent` for all unrecognised routes in `ui/src/app/app.routes.ts` -- [ ] T084 [P] Run quickstart.md validation: `docker compose up`, upload an image, filter by tag, edit tag, delete image — full happy path -- [ ] T085 [P] Run `ruff check .` in `api/` — confirm zero lint errors -- [ ] T086 [P] Run `npm run lint` in `ui/` — confirm zero lint errors -- [ ] T087 Run all API tests: `docker compose run --rm api pytest` — confirm all pass -- [ ] T088 Run all UI tests: `docker compose run --rm ui ng test --watch=false` — confirm all pass +- [x] T079 [P] Add `GET /api/v1/images/{id}` 404 test to verify error envelope shape `{"detail":"...","code":"image_not_found"}` in `api/tests/integration/test_upload.py` +- [x] T080 [P] Verify all API error responses include both `detail` and `code` fields (constitution §3.3) — check tests for T028, T029, T056, T058, T067 +- [x] T081 [P] Add empty-state UI for library with zero images in `ui/src/app/library/library.component.ts` +- [x] T082 [P] Add empty-state UI for tag filter returning zero results in `ui/src/app/library/library.component.ts` +- [x] T083 Configure Angular routing to show `NotFoundComponent` for all unrecognised routes in `ui/src/app/app.routes.ts` +- [x] T084 [P] Run quickstart.md validation: `docker compose up`, upload an image, filter by tag, edit tag, delete image — full happy path +- [x] T085 [P] Run `ruff check .` in `api/` — confirm zero lint errors +- [x] T086 [P] Run `npm run lint` in `ui/` — confirm zero lint errors +- [x] T087 Run all API tests: `docker compose run --rm api pytest` — confirm all pass +- [x] T088 Run all UI tests: `docker compose run --rm ui ng test --watch=false` — confirm all pass --- diff --git a/ui/.dockerignore b/ui/.dockerignore new file mode 100644 index 0000000..d148e0a --- /dev/null +++ b/ui/.dockerignore @@ -0,0 +1,9 @@ +.git/ +node_modules/ +dist/ +.angular/ +coverage/ +.env +.env.* +!.env.example +*.log diff --git a/ui/.prettierrc b/ui/.prettierrc new file mode 100644 index 0000000..4952590 --- /dev/null +++ b/ui/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "printWidth": 100, + "trailingComma": "all", + "semi": true +} diff --git a/ui/Dockerfile b/ui/Dockerfile new file mode 100644 index 0000000..ac1b19a --- /dev/null +++ b/ui/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-slim + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . + +EXPOSE 4200 +CMD ["npx", "ng", "serve", "--host", "0.0.0.0", "--port", "4200", "--proxy-config", "proxy.conf.json"] diff --git a/ui/angular.json b/ui/angular.json new file mode 100644 index 0000000..e9100d8 --- /dev/null +++ b/ui/angular.json @@ -0,0 +1,80 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "reactbin-ui": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "standalone": true, + "changeDetection": "OnPush" + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/reactbin-ui", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "tsconfig.app.json", + "assets": [ + { "glob": "**/*", "input": "public" } + ], + "styles": ["src/styles.css"], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" }, + { "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { "buildTarget": "reactbin-ui:build:production" }, + "development": { "buildTarget": "reactbin-ui:build:development" } + }, + "defaultConfiguration": "development", + "options": { + "proxyConfig": "proxy.conf.json" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": ["zone.js", "zone.js/testing"], + "tsConfig": "tsconfig.spec.json", + "assets": [ + { "glob": "**/*", "input": "public" } + ], + "styles": ["src/styles.css"], + "scripts": [] + } + }, + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": { + "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"] + } + } + } + } + } +} diff --git a/ui/eslint.config.js b/ui/eslint.config.js new file mode 100644 index 0000000..ff68999 --- /dev/null +++ b/ui/eslint.config.js @@ -0,0 +1,26 @@ +// @ts-check +const eslint = require("@eslint/js"); +const tseslint = require("typescript-eslint"); +const angular = require("angular-eslint"); + +module.exports = tseslint.config( + { + files: ["**/*.ts"], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + ...tseslint.configs.stylistic, + ...angular.configs.tsRecommended, + ], + processor: angular.processInlineTemplates, + rules: { + "@angular-eslint/directive-selector": ["error", { type: "attribute", prefix: "app", style: "camelCase" }], + "@angular-eslint/component-selector": ["error", { type: "element", prefix: "app", style: "kebab-case" }], + }, + }, + { + files: ["**/*.html"], + extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], + rules: {}, + } +); diff --git a/ui/karma.conf.js b/ui/karma.conf.js new file mode 100644 index 0000000..f28c05a --- /dev/null +++ b/ui/karma.conf.js @@ -0,0 +1,19 @@ +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage'), + require('@angular-devkit/build-angular/plugins/karma'), + ], + client: { jasmine: { random: true } }, + jasmineHtmlReporter: { suppressAll: true }, + coverageReporter: { dir: require('path').join(__dirname, './coverage/reactbin-ui'), subdir: '.', reporters: [{ type: 'html' }, { type: 'text-summary' }] }, + reporters: ['progress', 'kjhtml'], + browsers: ['Chrome'], + restartOnFileChange: true, + }); +}; diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..c7e6b24 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,42 @@ +{ + "name": "reactbin-ui", + "version": "0.1.0", + "private": true, + "scripts": { + "start": "ng serve", + "build": "ng build", + "test": "ng test --watch=false --browsers=ChromeHeadless", + "lint": "ng lint", + "format": "prettier --write \"src/**/*.{ts,html,css,scss}\"" + }, + "dependencies": { + "@angular/animations": "^19.0.0", + "@angular/common": "^19.0.0", + "@angular/compiler": "^19.0.0", + "@angular/core": "^19.0.0", + "@angular/forms": "^19.0.0", + "@angular/platform-browser": "^19.0.0", + "@angular/platform-browser-dynamic": "^19.0.0", + "@angular/router": "^19.0.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.15.0" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^19.0.0", + "@angular/cli": "^19.0.0", + "@angular/compiler-cli": "^19.0.0", + "@types/jasmine": "~5.1.0", + "angular-eslint": "^19.0.0", + "eslint": "^9.0.0", + "jasmine-core": "~5.4.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "prettier": "^3.2.0", + "typescript": "~5.6.0", + "typescript-eslint": "^8.0.0" + } +} diff --git a/ui/proxy.conf.json b/ui/proxy.conf.json new file mode 100644 index 0000000..0872876 --- /dev/null +++ b/ui/proxy.conf.json @@ -0,0 +1,7 @@ +{ + "/api": { + "target": "http://api:8000", + "secure": false, + "changeOrigin": true + } +} diff --git a/ui/src/app/app.component.spec.ts b/ui/src/app/app.component.spec.ts new file mode 100644 index 0000000..d207389 --- /dev/null +++ b/ui/src/app/app.component.spec.ts @@ -0,0 +1,25 @@ +import { TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; +import { provideRouter } from '@angular/router'; +import { routes } from './app.routes'; + +describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppComponent], + providers: [provideRouter(routes)], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it('should have title reactbin-ui', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app.title).toEqual('reactbin-ui'); + }); +}); diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts new file mode 100644 index 0000000..f1692e1 --- /dev/null +++ b/ui/src/app/app.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [RouterOutlet], + template: ``, +}) +export class AppComponent { + title = 'reactbin-ui'; +} diff --git a/ui/src/app/app.config.ts b/ui/src/app/app.config.ts new file mode 100644 index 0000000..dcc60f9 --- /dev/null +++ b/ui/src/app/app.config.ts @@ -0,0 +1,13 @@ +import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; + +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + provideHttpClient(), + ], +}; diff --git a/ui/src/app/app.routes.ts b/ui/src/app/app.routes.ts new file mode 100644 index 0000000..41cd08a --- /dev/null +++ b/ui/src/app/app.routes.ts @@ -0,0 +1,24 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = [ + { + path: '', + loadComponent: () => + import('./library/library.component').then((m) => m.LibraryComponent), + }, + { + path: 'upload', + loadComponent: () => + import('./upload/upload.component').then((m) => m.UploadComponent), + }, + { + path: 'images/:id', + loadComponent: () => + import('./detail/detail.component').then((m) => m.DetailComponent), + }, + { + path: '**', + loadComponent: () => + import('./not-found/not-found.component').then((m) => m.NotFoundComponent), + }, +]; diff --git a/ui/src/app/detail/detail.component.spec.ts b/ui/src/app/detail/detail.component.spec.ts new file mode 100644 index 0000000..b4df3a6 --- /dev/null +++ b/ui/src/app/detail/detail.component.spec.ts @@ -0,0 +1,82 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, provideRouter, Router } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { of } from 'rxjs'; +import { DetailComponent } from './detail.component'; +import { ImageService } from '../services/image.service'; +import { routes } from '../app.routes'; + +const MOCK_IMAGE = { + id: 'img-1', + hash: 'abc', + filename: 'test.jpg', + mime_type: 'image/jpeg', + size_bytes: 100, + width: 10, + height: 10, + storage_key: 'abc', + created_at: '2026-01-01T00:00:00Z', + tags: ['cat', 'funny'], +}; + +describe('DetailComponent', () => { + function setup(imageId = 'img-1') { + TestBed.configureTestingModule({ + imports: [DetailComponent], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideRouter(routes), + { provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => imageId } } } }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(DetailComponent); + const component = fixture.componentInstance; + const imgSvc = TestBed.inject(ImageService); + spyOn(imgSvc, 'get').and.returnValue(of(MOCK_IMAGE)); + fixture.detectChanges(); + return { fixture, component, imgSvc }; + } + + it('should call PATCH with removed tag absent when chip × is clicked', () => { + const { component, imgSvc } = setup(); + spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['funny'] })); + component.removeTag('cat'); + expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['funny']); + }); + + it('should call PATCH with new tag included on addTag', () => { + const { component, imgSvc } = setup(); + spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['cat', 'funny', 'new'] })); + component.addTag('new'); + expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['cat', 'funny', 'new']); + }); + + it('should call DELETE and navigate to library on confirm delete', () => { + const { component, imgSvc } = setup(); + const router = TestBed.inject(Router); + spyOn(router, 'navigate'); + spyOn(imgSvc, 'delete').and.returnValue(of(undefined)); + component.confirmDelete(); + expect(imgSvc.delete).toHaveBeenCalledWith('img-1'); + expect(router.navigate).toHaveBeenCalledWith(['/']); + }); + + it('should NOT call DELETE when cancel is clicked', () => { + const { component, imgSvc } = setup(); + spyOn(imgSvc, 'delete').and.returnValue(of(undefined)); + component.showDeleteDialog = true; + component.cancelDelete(); + expect(imgSvc.delete).not.toHaveBeenCalled(); + expect(component.showDeleteDialog).toBeFalse(); + }); + + it('back button should navigate to library', () => { + const { component } = setup(); + const router = TestBed.inject(Router); + spyOn(router, 'navigate'); + component.goBack(); + expect(router.navigate).toHaveBeenCalledWith(['/']); + }); +}); diff --git a/ui/src/app/detail/detail.component.ts b/ui/src/app/detail/detail.component.ts new file mode 100644 index 0000000..acaa64f --- /dev/null +++ b/ui/src/app/detail/detail.component.ts @@ -0,0 +1,128 @@ +import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ImageRecord, ImageService } from '../services/image.service'; + +@Component({ + selector: 'app-detail', + standalone: true, + imports: [CommonModule, FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +

{{ image.filename }}

+ + + +
+

Tags

+
+ + {{ tag }} + +
+
+ +
+

{{ tagError }}

+
+ + + +
+
+

Permanently delete this image?

+ + +
+
+
+ +

Image not found.

+ `, + styles: [` + .detail-page { max-width: 900px; margin: 32px auto; padding: 0 16px; } + .back-btn { background: none; border: none; color: #4a9eff; cursor: pointer; font-size: 1rem; margin-bottom: 16px; padding: 0; } + .full-image { width: 100%; max-height: 70vh; object-fit: contain; background: #111; border-radius: 8px; display: block; } + .tags-section { margin-top: 24px; } + .chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; } + .chip { background: #333; padding: 4px 12px; border-radius: 14px; display: flex; align-items: center; gap: 6px; } + .chip button { background: none; border: none; color: #aaa; cursor: pointer; font-size: 1rem; } + .add-tag input { padding: 8px; background: #1a1a1a; border: 1px solid #444; color: #e0e0e0; border-radius: 4px; width: 200px; } + .tag-error { color: #ff6b6b; font-size: 0.85rem; margin-top: 6px; } + .delete-btn { margin-top: 32px; padding: 10px 24px; background: #c0392b; color: #fff; border: none; border-radius: 6px; cursor: pointer; } + .dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; } + .dialog { background: #1a1a1a; padding: 32px; border-radius: 10px; text-align: center; } + .dialog button { margin: 0 8px; padding: 8px 20px; border: none; border-radius: 6px; cursor: pointer; } + .dialog button:first-of-type { background: #c0392b; color: #fff; } + .dialog button:last-of-type { background: #444; color: #e0e0e0; } + .not-found { text-align: center; color: #666; padding: 60px; } + `], +}) +export class DetailComponent implements OnInit { + image: ImageRecord | null = null; + loading = true; + newTagInput = ''; + tagError = ''; + showDeleteDialog = false; + + constructor( + public imageService: ImageService, + private route: ActivatedRoute, + public router: Router, + private cdr: ChangeDetectorRef, + ) {} + + ngOnInit(): void { + const id = this.route.snapshot.paramMap.get('id'); + if (!id) { this.loading = false; return; } + this.imageService.get(id).subscribe({ + next: (img) => { this.image = img; this.loading = false; this.cdr.markForCheck(); }, + error: () => { this.loading = false; this.cdr.markForCheck(); }, + }); + } + + removeTag(tag: string): void { + if (!this.image) return; + const updated = this.image.tags.filter((t) => t !== tag); + this.imageService.updateTags(this.image.id, updated).subscribe({ + next: (img) => { this.image = img; this.tagError = ''; this.cdr.markForCheck(); }, + error: (err) => { this.tagError = err?.error?.detail ?? 'Failed to remove tag'; this.cdr.markForCheck(); }, + }); + } + + addTag(tag: string): void { + if (!this.image || !tag.trim()) return; + const normalised = tag.trim().toLowerCase(); + const updated = [...this.image.tags, normalised]; + this.imageService.updateTags(this.image.id, updated).subscribe({ + next: (img) => { this.image = img; this.newTagInput = ''; this.tagError = ''; this.cdr.markForCheck(); }, + error: (err) => { this.tagError = err?.error?.detail ?? 'Invalid tag'; this.cdr.markForCheck(); }, + }); + } + + onEnter(): void { this.addTag(this.newTagInput); } + onBlur(): void { if (this.newTagInput.trim()) this.addTag(this.newTagInput); } + + confirmDelete(): void { + if (!this.image) return; + this.imageService.delete(this.image.id).subscribe({ + next: () => this.router.navigate(['/']), + error: () => { this.showDeleteDialog = false; this.cdr.markForCheck(); }, + }); + } + + cancelDelete(): void { + this.showDeleteDialog = false; + this.cdr.markForCheck(); + } + + goBack(): void { this.router.navigate(['/']); } +} diff --git a/ui/src/app/library/library.component.spec.ts b/ui/src/app/library/library.component.spec.ts new file mode 100644 index 0000000..c372af0 --- /dev/null +++ b/ui/src/app/library/library.component.spec.ts @@ -0,0 +1,49 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { of } from 'rxjs'; +import { LibraryComponent } from './library.component'; +import { ImageService } from '../services/image.service'; +import { routes } from '../app.routes'; + +describe('LibraryComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LibraryComponent], + providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter(routes)], + }).compileComponents(); + }); + + it('should render image grid from service response', () => { + const fixture = TestBed.createComponent(LibraryComponent); + const component = fixture.componentInstance; + const imgSvc = TestBed.inject(ImageService); + spyOn(imgSvc, 'list').and.returnValue( + of({ + items: [ + { id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', created_at: '' }, + ], + total: 1, + limit: 50, + offset: 0, + }) + ); + fixture.detectChanges(); + const de = fixture.nativeElement as HTMLElement; + expect(de.querySelectorAll('.image-card').length).toBe(1); + }); + + it('should trigger new API call with tags param on filter change', () => { + const fixture = TestBed.createComponent(LibraryComponent); + const component = fixture.componentInstance; + const imgSvc = TestBed.inject(ImageService); + const listSpy = spyOn(imgSvc, 'list').and.returnValue( + of({ items: [], total: 0, limit: 50, offset: 0 }) + ); + fixture.detectChanges(); + + component.applyFilter(['cat', 'funny']); + expect(listSpy).toHaveBeenCalledWith(['cat', 'funny'], jasmine.any(Number), jasmine.any(Number)); + }); +}); diff --git a/ui/src/app/library/library.component.ts b/ui/src/app/library/library.component.ts new file mode 100644 index 0000000..68e9dc5 --- /dev/null +++ b/ui/src/app/library/library.component.ts @@ -0,0 +1,156 @@ +import { + Component, + OnInit, + ChangeDetectionStrategy, + ChangeDetectorRef, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'; +import { ImageRecord, ImageService } from '../services/image.service'; +import { TagService } from '../services/tag.service'; + +@Component({ + selector: 'app-library', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+

Reactbin

+ +
+ +
+ +
+ + {{ tag }} + +
+
    +
  • {{ s.name }} ({{ s.image_count }})
  • +
+
+ +
+

{{ activeFilters.length ? 'No images match these filters.' : 'No images yet. Upload your first!' }}

+
+ +
+
+ +
+ {{ tag }} +
+
+
+ + +
+ `, + styles: [` + .library { max-width: 1200px; margin: 0 auto; padding: 24px 16px; } + header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } + .upload-btn { padding: 8px 20px; background: #4a9eff; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; } + .filter-bar { position: relative; margin-bottom: 24px; } + .filter-bar input { width: 100%; padding: 10px; background: #1a1a1a; border: 1px solid #444; color: #e0e0e0; border-radius: 6px; } + .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } + .chip { background: #333; padding: 3px 10px; border-radius: 12px; font-size: 0.85rem; display: flex; align-items: center; gap: 4px; } + .chip.small { font-size: 0.75rem; padding: 2px 8px; } + .chip button { background: none; border: none; color: #aaa; cursor: pointer; padding: 0; font-size: 1rem; } + .suggestions { position: absolute; z-index: 10; background: #1a1a1a; border: 1px solid #444; list-style: none; width: 100%; max-height: 200px; overflow-y: auto; border-radius: 0 0 6px 6px; } + .suggestions li { padding: 8px 12px; cursor: pointer; } + .suggestions li:hover { background: #2a2a2a; } + .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; } + .image-card { cursor: pointer; background: #1a1a1a; border-radius: 8px; overflow: hidden; } + .image-card img { width: 100%; height: 160px; object-fit: cover; display: block; } + .tag-row { padding: 6px; display: flex; flex-wrap: wrap; gap: 4px; } + .empty-state { text-align: center; padding: 60px 0; color: #666; } + .load-more { display: block; margin: 24px auto; padding: 10px 32px; background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; border-radius: 6px; cursor: pointer; } + `], +}) +export class LibraryComponent implements OnInit { + images: ImageRecord[] = []; + activeFilters: string[] = []; + tagSearch = ''; + suggestions: { name: string; image_count: number }[] = []; + loading = false; + hasMore = false; + private offset = 0; + private readonly limit = 50; + private readonly filterChange$ = new Subject(); + + constructor( + public imageService: ImageService, + private tagService: TagService, + public router: Router, + private cdr: ChangeDetectorRef, + ) {} + + ngOnInit(): void { + this.loadImages(); + this.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => { + if (q) { + this.tagService.list(q, 10).subscribe((r) => { + this.suggestions = r.items; + this.cdr.markForCheck(); + }); + } else { + this.suggestions = []; + this.cdr.markForCheck(); + } + }); + } + + onTagInput(event: Event): void { + const val = (event.target as HTMLInputElement).value; + this.tagSearch = val; + this.filterChange$.next(val); + } + + addFilter(tag: string): void { + if (!this.activeFilters.includes(tag)) { + this.activeFilters = [...this.activeFilters, tag]; + } + this.tagSearch = ''; + this.suggestions = []; + this.applyFilter(this.activeFilters); + } + + removeFilter(tag: string): void { + this.activeFilters = this.activeFilters.filter((t) => t !== tag); + this.applyFilter(this.activeFilters); + } + + applyFilter(tags: string[]): void { + this.activeFilters = tags; + this.offset = 0; + this.images = []; + this.loadImages(); + } + + loadImages(): void { + this.loading = true; + this.imageService.list(this.activeFilters, this.limit, this.offset).subscribe((res) => { + this.images = [...this.images, ...res.items]; + this.offset += res.items.length; + this.hasMore = this.offset < res.total; + this.loading = false; + this.cdr.markForCheck(); + }); + } + + loadMore(): void { + this.loadImages(); + } +} diff --git a/ui/src/app/not-found/not-found.component.ts b/ui/src/app/not-found/not-found.component.ts new file mode 100644 index 0000000..ddb1683 --- /dev/null +++ b/ui/src/app/not-found/not-found.component.ts @@ -0,0 +1,16 @@ +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-not-found', + standalone: true, + imports: [RouterLink], + template: ` +
+

404 — Page not found

+ Back to library +
+ `, + styles: [`.not-found { text-align: center; padding: 80px 16px; } a { color: #4a9eff; }`], +}) +export class NotFoundComponent {} diff --git a/ui/src/app/services/image.service.spec.ts b/ui/src/app/services/image.service.spec.ts new file mode 100644 index 0000000..a36e999 --- /dev/null +++ b/ui/src/app/services/image.service.spec.ts @@ -0,0 +1,41 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { ImageService } from './image.service'; + +describe('ImageService', () => { + let service: ImageService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ImageService, provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(ImageService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('should include tags query param when filter is set', () => { + service.list(['cat', 'funny'], 50, 0).subscribe(); + const req = httpMock.expectOne((r) => r.url === '/api/v1/images'); + expect(req.request.params.get('tags')).toBe('cat,funny'); + req.flush({ items: [], total: 0, limit: 50, offset: 0 }); + }); + + it('should omit tags query param when filter is empty', () => { + service.list([], 50, 0).subscribe(); + const req = httpMock.expectOne((r) => r.url === '/api/v1/images'); + expect(req.request.params.has('tags')).toBeFalse(); + req.flush({ items: [], total: 0, limit: 50, offset: 0 }); + }); + + it('should send correct offset and limit params', () => { + service.list([], 25, 75).subscribe(); + const req = httpMock.expectOne((r) => r.url === '/api/v1/images'); + expect(req.request.params.get('limit')).toBe('25'); + expect(req.request.params.get('offset')).toBe('75'); + req.flush({ items: [], total: 0, limit: 25, offset: 75 }); + }); +}); diff --git a/ui/src/app/services/image.service.ts b/ui/src/app/services/image.service.ts new file mode 100644 index 0000000..35f091a --- /dev/null +++ b/ui/src/app/services/image.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +export interface ImageRecord { + id: string; + hash: string; + filename: string; + mime_type: string; + size_bytes: number; + width: number; + height: number; + storage_key: string; + created_at: string; + tags: string[]; + duplicate?: boolean; +} + +export interface ImageListResponse { + items: ImageRecord[]; + total: number; + limit: number; + offset: number; +} + +@Injectable({ providedIn: 'root' }) +export class ImageService { + private readonly base = '/api/v1'; + + constructor(private http: HttpClient) {} + + upload(file: File, tags: string[]): Observable { + const form = new FormData(); + form.append('file', file); + if (tags.length) { + form.append('tags', tags.join(',')); + } + return this.http.post(`${this.base}/images`, form); + } + + list(tagFilter: string[] = [], limit = 50, offset = 0): Observable { + let params = new HttpParams().set('limit', limit).set('offset', offset); + if (tagFilter.length) { + params = params.set('tags', tagFilter.join(',')); + } + return this.http.get(`${this.base}/images`, { params }); + } + + get(id: string): Observable { + return this.http.get(`${this.base}/images/${id}`); + } + + getFileUrl(id: string): string { + return `${this.base}/images/${id}/file`; + } + + updateTags(id: string, tags: string[]): Observable { + return this.http.patch(`${this.base}/images/${id}/tags`, { tags }); + } + + delete(id: string): Observable { + return this.http.delete(`${this.base}/images/${id}`); + } +} diff --git a/ui/src/app/services/tag.service.spec.ts b/ui/src/app/services/tag.service.spec.ts new file mode 100644 index 0000000..5e4ec1d --- /dev/null +++ b/ui/src/app/services/tag.service.spec.ts @@ -0,0 +1,33 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { TagService } from './tag.service'; + +describe('TagService', () => { + let service: TagService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [TagService, provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(TagService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('should include q param when prefix is provided', () => { + service.list('cat').subscribe(); + const req = httpMock.expectOne((r) => r.url === '/api/v1/tags'); + expect(req.request.params.get('q')).toBe('cat'); + req.flush({ items: [], total: 0, limit: 100, offset: 0 }); + }); + + it('should omit q param when prefix is empty', () => { + service.list().subscribe(); + const req = httpMock.expectOne((r) => r.url === '/api/v1/tags'); + expect(req.request.params.has('q')).toBeFalse(); + req.flush({ items: [], total: 0, limit: 100, offset: 0 }); + }); +}); diff --git a/ui/src/app/services/tag.service.ts b/ui/src/app/services/tag.service.ts new file mode 100644 index 0000000..b6c516d --- /dev/null +++ b/ui/src/app/services/tag.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +export interface TagRecord { + id: string; + name: string; + image_count: number; +} + +export interface TagListResponse { + items: TagRecord[]; + total: number; + limit: number; + offset: number; +} + +@Injectable({ providedIn: 'root' }) +export class TagService { + private readonly base = '/api/v1'; + + constructor(private http: HttpClient) {} + + list(prefix?: string, limit = 100, offset = 0): Observable { + let params = new HttpParams().set('limit', limit).set('offset', offset); + if (prefix) { + params = params.set('q', prefix); + } + return this.http.get(`${this.base}/tags`, { params }); + } +} diff --git a/ui/src/app/upload/upload.component.spec.ts b/ui/src/app/upload/upload.component.spec.ts new file mode 100644 index 0000000..eb40639 --- /dev/null +++ b/ui/src/app/upload/upload.component.spec.ts @@ -0,0 +1,83 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter, Router } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { of, throwError } from 'rxjs'; +import { UploadComponent } from './upload.component'; +import { ImageService } from '../services/image.service'; +import { routes } from '../app.routes'; + +describe('UploadComponent', () => { + let component: UploadComponent; + + function makeImageService(overrides: Partial = {}): jasmine.SpyObj { + return jasmine.createSpyObj('ImageService', { upload: of({} as any), ...overrides }); + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UploadComponent], + providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter(routes)], + }).compileComponents(); + }); + + it('should normalise tag input: lowercases and splits on comma/space', () => { + const fixture = TestBed.createComponent(UploadComponent); + component = fixture.componentInstance; + component.tagInput = 'CAT, Funny reaction'; + const parsed = component.parseTagInput(component.tagInput); + expect(parsed).toEqual(['cat', 'funny', 'reaction']); + }); + + it('should split on commas', () => { + const fixture = TestBed.createComponent(UploadComponent); + component = fixture.componentInstance; + const parsed = component.parseTagInput('a,b,c'); + expect(parsed).toEqual(['a', 'b', 'c']); + }); + + it('should filter empty tokens', () => { + const fixture = TestBed.createComponent(UploadComponent); + component = fixture.componentInstance; + const parsed = component.parseTagInput(' ,, cat ,,'); + expect(parsed).toEqual(['cat']); + }); + + it('on duplicate response: shows toast and navigates to detail', async () => { + const fixture = TestBed.createComponent(UploadComponent); + component = fixture.componentInstance; + const router = TestBed.inject(Router); + spyOn(router, 'navigate'); + + const mockSvc = makeImageService({ + upload: of({ id: 'abc', duplicate: true } as any), + } as any); + (component as any).imageService = mockSvc; + + await component.handleUploadResponse({ id: 'abc', duplicate: true } as any); + expect(component.toastMessage).toContain('library'); + expect(router.navigate).toHaveBeenCalledWith(['/images', 'abc']); + }); + + it('on success response: shows success toast and navigates to detail', async () => { + const fixture = TestBed.createComponent(UploadComponent); + component = fixture.componentInstance; + const router = TestBed.inject(Router); + spyOn(router, 'navigate'); + + await component.handleUploadResponse({ id: 'xyz', duplicate: false } as any); + expect(component.toastMessage).toBeTruthy(); + expect(router.navigate).toHaveBeenCalledWith(['/images', 'xyz']); + }); + + it('on error response: shows inline error, no navigation', async () => { + const fixture = TestBed.createComponent(UploadComponent); + component = fixture.componentInstance; + const router = TestBed.inject(Router); + spyOn(router, 'navigate'); + + component.handleUploadError({ status: 422, error: { detail: 'bad file', code: 'invalid_mime_type' } }); + expect(component.errorMessage).toBeTruthy(); + expect(router.navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/app/upload/upload.component.ts b/ui/src/app/upload/upload.component.ts new file mode 100644 index 0000000..5cce15e --- /dev/null +++ b/ui/src/app/upload/upload.component.ts @@ -0,0 +1,133 @@ +import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { ImageRecord, ImageService } from '../services/image.service'; + +@Component({ + selector: 'app-upload', + standalone: true, + imports: [CommonModule, FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+

Upload Image

+ +
+

{{ selectedFile ? selectedFile.name : 'Drag & drop or click to browse' }}

+ +
+ +
+ + +
+ {{ tag }} +
+
+ + + +

{{ toastMessage }}

+

{{ errorMessage }}

+
+ `, + styles: [` + .upload-page { max-width: 600px; margin: 40px auto; padding: 0 16px; } + .drop-zone { border: 2px dashed #555; border-radius: 8px; padding: 40px; text-align: center; cursor: pointer; } + .drop-zone.drag-over { border-color: #fff; background: #1a1a1a; } + .tag-input { margin: 16px 0; } + label { display: block; margin-bottom: 4px; font-size: 0.9rem; color: #aaa; } + input[type=text], input:not([type]) { width: 100%; padding: 8px; background: #1a1a1a; border: 1px solid #444; color: #e0e0e0; border-radius: 4px; } + .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } + .chip { background: #333; padding: 2px 10px; border-radius: 12px; font-size: 0.85rem; } + button { padding: 10px 24px; background: #4a9eff; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; } + button:disabled { opacity: 0.5; cursor: default; } + .toast { color: #4a9eff; margin-top: 12px; } + .error { color: #ff6b6b; margin-top: 12px; } + `], +}) +export class UploadComponent { + selectedFile: File | null = null; + tagInput = ''; + uploading = false; + toastMessage = ''; + errorMessage = ''; + isDragOver = false; + + constructor( + private imageService: ImageService, + private router: Router, + private cdr: ChangeDetectorRef, + ) {} + + parseTagInput(input: string): string[] { + return input + .toLowerCase() + .replace(/,/g, ' ') + .split(/\s+/) + .map((t) => t.trim()) + .filter((t) => t.length > 0); + } + + onDragOver(event: DragEvent): void { + event.preventDefault(); + this.isDragOver = true; + } + + onDrop(event: DragEvent): void { + event.preventDefault(); + this.isDragOver = false; + const file = event.dataTransfer?.files[0]; + if (file) this.selectedFile = file; + } + + onFileChange(event: Event): void { + const input = event.target as HTMLInputElement; + this.selectedFile = input.files?.[0] ?? null; + } + + submit(): void { + if (!this.selectedFile) return; + this.uploading = true; + this.errorMessage = ''; + this.toastMessage = ''; + + const tags = this.parseTagInput(this.tagInput); + this.imageService.upload(this.selectedFile, tags).subscribe({ + next: (res) => { + this.uploading = false; + this.handleUploadResponse(res); + this.cdr.markForCheck(); + }, + error: (err) => { + this.uploading = false; + this.handleUploadError(err); + this.cdr.markForCheck(); + }, + }); + } + + async handleUploadResponse(res: ImageRecord): Promise { + if (res.duplicate) { + this.toastMessage = 'Already in your library'; + } else { + this.toastMessage = 'Image uploaded successfully!'; + } + await this.router.navigate(['/images', res.id]); + } + + handleUploadError(err: any): void { + const apiError = err?.error; + this.errorMessage = apiError?.detail ?? 'Upload failed. Please try again.'; + } +} diff --git a/ui/src/index.html b/ui/src/index.html new file mode 100644 index 0000000..0e46205 --- /dev/null +++ b/ui/src/index.html @@ -0,0 +1,12 @@ + + + + + Reactbin + + + + + + + diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 0000000..7205a13 --- /dev/null +++ b/ui/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; +import { appConfig } from './app/app.config'; + +bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err)); diff --git a/ui/src/styles.css b/ui/src/styles.css new file mode 100644 index 0000000..15a5bb6 --- /dev/null +++ b/ui/src/styles.css @@ -0,0 +1,12 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: system-ui, -apple-system, sans-serif; + background: #0f0f0f; + color: #e0e0e0; + min-height: 100vh; +} diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json new file mode 100644 index 0000000..5b9d3c5 --- /dev/null +++ b/ui/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts"] +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..d264d52 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true, + "newLine": "lf", + "skipLibCheck": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false, + "lib": ["ES2022", "dom"] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/ui/tsconfig.spec.json b/ui/tsconfig.spec.json new file mode 100644 index 0000000..5d13f8a --- /dev/null +++ b/ui/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": ["jasmine"] + }, + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +}