[Spec Kit] Implementation progress
Implements all 88 tasks for the Reaction Image Board (specs/001-reaction-image-board): - docker-compose.yml: postgres, minio, minio-init, api, ui services with healthchecks - api/: FastAPI app with SQLAlchemy 2.x async, Alembic migrations, S3/MinIO storage, full integration + unit test suite (pytest + pytest-asyncio) - ui/: Angular 19 standalone app (Library, Upload, Detail, NotFound components) - .env.example: all required environment variables - .gitignore: Python, Node, Docker, IDE, .env patterns Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal file
@@ -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
|
||||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@@ -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/
|
||||||
14
api/.dockerignore
Normal file
14
api/.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.git/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
12
api/Dockerfile
Normal file
12
api/Dockerfile
Normal file
@@ -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"]
|
||||||
41
api/alembic.ini
Normal file
41
api/alembic.ini
Normal file
@@ -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
|
||||||
60
api/alembic/env.py
Normal file
60
api/alembic/env.py
Normal file
@@ -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()
|
||||||
25
api/alembic/script.py.mako
Normal file
25
api/alembic/script.py.mako
Normal file
@@ -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"}
|
||||||
63
api/alembic/versions/001_initial_schema.py
Normal file
63
api/alembic/versions/001_initial_schema.py
Normal file
@@ -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")
|
||||||
0
api/app/__init__.py
Normal file
0
api/app/__init__.py
Normal file
0
api/app/auth/__init__.py
Normal file
0
api/app/auth/__init__.py
Normal file
8
api/app/auth/noop.py
Normal file
8
api/app/auth/noop.py
Normal file
@@ -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
|
||||||
14
api/app/auth/provider.py
Normal file
14
api/app/auth/provider.py
Normal file
@@ -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."""
|
||||||
20
api/app/config.py
Normal file
20
api/app/config.py
Normal file
@@ -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()
|
||||||
26
api/app/database.py
Normal file
26
api/app/database.py
Normal file
@@ -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
|
||||||
34
api/app/dependencies.py
Normal file
34
api/app/dependencies.py
Normal file
@@ -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
|
||||||
33
api/app/main.py
Normal file
33
api/app/main.py
Normal file
@@ -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")
|
||||||
61
api/app/models.py
Normal file
61
api/app/models.py
Normal file
@@ -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")
|
||||||
0
api/app/repositories/__init__.py
Normal file
0
api/app/repositories/__init__.py
Normal file
84
api/app/repositories/image_repo.py
Normal file
84
api/app/repositories/image_repo.py
Normal file
@@ -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()
|
||||||
102
api/app/repositories/tag_repo.py
Normal file
102
api/app/repositories/tag_repo.py
Normal file
@@ -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
|
||||||
0
api/app/routers/__init__.py
Normal file
0
api/app/routers/__init__.py
Normal file
271
api/app/routers/images.py
Normal file
271
api/app/routers/images.py
Normal file
@@ -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("<HH", data[6:10])
|
||||||
|
return w, h
|
||||||
|
|
||||||
|
|
||||||
|
def _webp_dimensions(data: bytes) -> tuple[int, int]:
|
||||||
|
if data[8:12] == b"VP8 ":
|
||||||
|
w = struct.unpack("<H", data[26:28])[0] & 0x3FFF
|
||||||
|
h = struct.unpack("<H", data[28:30])[0] & 0x3FFF
|
||||||
|
return w, h
|
||||||
|
elif data[8:12] == b"VP8L":
|
||||||
|
bits = struct.unpack("<I", data[21:25])[0]
|
||||||
|
w = (bits & 0x3FFF) + 1
|
||||||
|
h = ((bits >> 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)
|
||||||
20
api/app/routers/tags.py
Normal file
20
api/app/routers/tags.py
Normal file
@@ -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}
|
||||||
0
api/app/storage/__init__.py
Normal file
0
api/app/storage/__init__.py
Normal file
15
api/app/storage/backend.py
Normal file
15
api/app/storage/backend.py
Normal file
@@ -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."""
|
||||||
46
api/app/storage/s3_backend.py
Normal file
46
api/app/storage/s3_backend.py
Normal file
@@ -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)
|
||||||
5
api/app/utils.py
Normal file
5
api/app/utils.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import hashlib
|
||||||
|
|
||||||
|
|
||||||
|
def compute_sha256(data: bytes) -> str:
|
||||||
|
return hashlib.sha256(data).hexdigest()
|
||||||
21
api/app/validation.py
Normal file
21
api/app/validation.py
Normal file
@@ -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")
|
||||||
42
api/pyproject.toml
Normal file
42
api/pyproject.toml
Normal file
@@ -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*"]
|
||||||
0
api/tests/__init__.py
Normal file
0
api/tests/__init__.py
Normal file
0
api/tests/integration/__init__.py
Normal file
0
api/tests/integration/__init__.py
Normal file
59
api/tests/integration/conftest.py
Normal file
59
api/tests/integration/conftest.py
Normal file
@@ -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()
|
||||||
60
api/tests/integration/test_delete.py
Normal file
60
api/tests/integration/test_delete.py
Normal file
@@ -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"
|
||||||
8
api/tests/integration/test_health.py
Normal file
8
api/tests/integration/test_health.py
Normal file
@@ -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"}
|
||||||
60
api/tests/integration/test_search.py
Normal file
60
api/tests/integration/test_search.py
Normal file
@@ -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
|
||||||
41
api/tests/integration/test_serving.py
Normal file
41
api/tests/integration/test_serving.py
Normal file
@@ -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"
|
||||||
132
api/tests/integration/test_tags.py
Normal file
132
api/tests/integration/test_tags.py
Normal file
@@ -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"])
|
||||||
100
api/tests/integration/test_upload.py
Normal file
100
api/tests/integration/test_upload.py
Normal file
@@ -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
|
||||||
0
api/tests/unit/__init__.py
Normal file
0
api/tests/unit/__init__.py
Normal file
40
api/tests/unit/test_config.py
Normal file
40
api/tests/unit/test_config.py
Normal file
@@ -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
|
||||||
20
api/tests/unit/test_hashing.py
Normal file
20
api/tests/unit/test_hashing.py
Normal file
@@ -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)
|
||||||
42
api/tests/unit/test_tags.py
Normal file
42
api/tests/unit/test_tags.py
Normal file
@@ -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"
|
||||||
34
api/tests/unit/test_validation.py
Normal file
34
api/tests/unit/test_validation.py
Normal file
@@ -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)
|
||||||
85
docker-compose.yml
Normal file
85
docker-compose.yml
Normal file
@@ -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:
|
||||||
@@ -40,20 +40,20 @@ ui/src/app/<feature>/ Angular component dirs (+ .spec.ts colocated)
|
|||||||
**Purpose**: Establish the monorepo layout, Docker Compose stack, and linting
|
**Purpose**: Establish the monorepo layout, Docker Compose stack, and linting
|
||||||
baseline. No feature logic. All subsequent milestones build on this.
|
baseline. No feature logic. All subsequent milestones build on this.
|
||||||
|
|
||||||
- [ ] T001 Create top-level monorepo layout: `api/`, `ui/`, `docker-compose.yml`, `.env.example`
|
- [x] 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)
|
- [x] 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)
|
- [x] 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)
|
- [x] 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`)
|
- [x] 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)
|
- [x] 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
|
- [x] 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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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)
|
- [x] 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)
|
- [x] 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
|
- [x] 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] 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
|
**Checkpoint**: `docker compose up` starts all four services. Health endpoint
|
||||||
returns 200. Both linters pass. All tests pass.
|
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.
|
**⚠️ 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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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`)
|
- [x] 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/`
|
- [x] T022 Write Alembic migration for `images` table in `api/alembic/versions/`
|
||||||
- [ ] T023 Implement `Image` SQLAlchemy model in `api/app/models.py`
|
- [x] 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`
|
- [x] 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] 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.
|
**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**
|
> **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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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`
|
- [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`
|
||||||
- [ ] T031 [P] [US1] Angular unit test: `duplicate: true` response → toast shown, navigate to detail 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`
|
||||||
- [ ] T032 [P] [US1] Angular unit test: `duplicate: false` response → success toast, 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`
|
||||||
- [ ] T033 [P] [US1] Angular unit test: error response → inline error shown, no navigation 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
|
### 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`
|
- [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`
|
||||||
- [ ] 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] 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] 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
|
**Checkpoint**: Full upload flow works in browser. Duplicate detection gives
|
||||||
correct feedback. API tests and Angular unit tests all pass.
|
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**
|
> **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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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`
|
- [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`
|
||||||
- [ ] T042 [P] [US2] Integration test: same query excludes images with only one matching tag 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`
|
||||||
- [ ] T043 [P] [US2] Angular unit test: `ImageService` constructs correct query params from filter state in `ui/src/app/services/image.service.spec.ts`
|
- [x] 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`
|
- [x] 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] 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
|
### Implementation for User Story 2
|
||||||
|
|
||||||
- [ ] T046 [US2] Write Alembic migration for `tags` and `image_tags` tables in `api/alembic/versions/`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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] 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
|
**Checkpoint**: Library view shows real images with tags. Tag filtering (AND
|
||||||
logic) and pagination work end-to-end.
|
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**
|
> **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`
|
- [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`
|
||||||
- [ ] T056 [P] [US3] Integration test: `/file` for unknown ID → 404 `image_not_found` 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`
|
||||||
- [ ] 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] 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`
|
- [x] 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`
|
- [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`
|
||||||
- [ ] 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] 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
|
### 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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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] 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
|
**Checkpoint**: Full-size image loads in browser via redirect. Tag editing
|
||||||
works from detail page. Changes persist across page navigation.
|
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**
|
> **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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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] 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
|
### 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`
|
- [x] 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`
|
- [x] 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] 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.
|
**Checkpoint**: Full CRUD loop works: upload → view → re-tag → delete.
|
||||||
Deleted images gone from library and storage.
|
Deleted images gone from library and storage.
|
||||||
@@ -230,15 +230,15 @@ matching tags remain.
|
|||||||
|
|
||||||
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
> **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`
|
- [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`
|
||||||
- [ ] T074 [P] [US5] Integration test: `GET /api/v1/tags?q=ca` returns only tags prefixed "ca" 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`
|
||||||
- [ ] 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] 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
|
### 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`
|
- [x] 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`
|
- [x] 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] 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.
|
**Checkpoint**: All user stories independently functional and tested.
|
||||||
|
|
||||||
@@ -248,16 +248,16 @@ matching tags remain.
|
|||||||
|
|
||||||
**Purpose**: Improvements affecting multiple user stories and final validation.
|
**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`
|
- [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`
|
||||||
- [ ] 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] 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`
|
- [x] 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`
|
- [x] 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`
|
- [x] 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
|
- [x] 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
|
- [x] T085 [P] Run `ruff check .` in `api/` — confirm zero lint errors
|
||||||
- [ ] T086 [P] Run `npm run lint` in `ui/` — confirm zero lint errors
|
- [x] 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
|
- [x] 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] T088 Run all UI tests: `docker compose run --rm ui ng test --watch=false` — confirm all pass
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
9
ui/.dockerignore
Normal file
9
ui/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.git/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.angular/
|
||||||
|
coverage/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
6
ui/.prettierrc
Normal file
6
ui/.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"semi": true
|
||||||
|
}
|
||||||
11
ui/Dockerfile
Normal file
11
ui/Dockerfile
Normal file
@@ -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"]
|
||||||
80
ui/angular.json
Normal file
80
ui/angular.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
ui/eslint.config.js
Normal file
26
ui/eslint.config.js
Normal file
@@ -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: {},
|
||||||
|
}
|
||||||
|
);
|
||||||
19
ui/karma.conf.js
Normal file
19
ui/karma.conf.js
Normal file
@@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
42
ui/package.json
Normal file
42
ui/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
ui/proxy.conf.json
Normal file
7
ui/proxy.conf.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://api:8000",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
25
ui/src/app/app.component.spec.ts
Normal file
25
ui/src/app/app.component.spec.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
12
ui/src/app/app.component.ts
Normal file
12
ui/src/app/app.component.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterOutlet],
|
||||||
|
template: `<router-outlet />`,
|
||||||
|
})
|
||||||
|
export class AppComponent {
|
||||||
|
title = 'reactbin-ui';
|
||||||
|
}
|
||||||
13
ui/src/app/app.config.ts
Normal file
13
ui/src/app/app.config.ts
Normal file
@@ -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(),
|
||||||
|
],
|
||||||
|
};
|
||||||
24
ui/src/app/app.routes.ts
Normal file
24
ui/src/app/app.routes.ts
Normal file
@@ -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),
|
||||||
|
},
|
||||||
|
];
|
||||||
82
ui/src/app/detail/detail.component.spec.ts
Normal file
82
ui/src/app/detail/detail.component.spec.ts
Normal file
@@ -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(['/']);
|
||||||
|
});
|
||||||
|
});
|
||||||
128
ui/src/app/detail/detail.component.ts
Normal file
128
ui/src/app/detail/detail.component.ts
Normal file
@@ -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: `
|
||||||
|
<div class="detail-page" *ngIf="image">
|
||||||
|
<button class="back-btn" (click)="goBack()">← Back</button>
|
||||||
|
<h2>{{ image.filename }}</h2>
|
||||||
|
|
||||||
|
<img class="full-image" [src]="imageService.getFileUrl(image.id)" [alt]="image.filename" />
|
||||||
|
|
||||||
|
<section class="tags-section">
|
||||||
|
<h3>Tags</h3>
|
||||||
|
<div class="chips">
|
||||||
|
<span *ngFor="let tag of image.tags" class="chip">
|
||||||
|
{{ tag }} <button (click)="removeTag(tag)">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="add-tag">
|
||||||
|
<input
|
||||||
|
[(ngModel)]="newTagInput"
|
||||||
|
placeholder="Add tag…"
|
||||||
|
(keydown.enter)="onEnter()"
|
||||||
|
(blur)="onBlur()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="tag-error" *ngIf="tagError">{{ tagError }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button class="delete-btn" (click)="showDeleteDialog = true">Delete Image</button>
|
||||||
|
|
||||||
|
<div class="dialog-overlay" *ngIf="showDeleteDialog">
|
||||||
|
<div class="dialog">
|
||||||
|
<p>Permanently delete this image?</p>
|
||||||
|
<button (click)="confirmDelete()">Delete</button>
|
||||||
|
<button (click)="cancelDelete()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p *ngIf="!image && !loading" class="not-found">Image not found.</p>
|
||||||
|
`,
|
||||||
|
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(['/']); }
|
||||||
|
}
|
||||||
49
ui/src/app/library/library.component.spec.ts
Normal file
49
ui/src/app/library/library.component.spec.ts
Normal file
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
156
ui/src/app/library/library.component.ts
Normal file
156
ui/src/app/library/library.component.ts
Normal file
@@ -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: `
|
||||||
|
<div class="library">
|
||||||
|
<header>
|
||||||
|
<h1>Reactbin</h1>
|
||||||
|
<button class="upload-btn" (click)="router.navigate(['/upload'])">Upload</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<input
|
||||||
|
placeholder="Filter by tag…"
|
||||||
|
(input)="onTagInput($event)"
|
||||||
|
[value]="tagSearch"
|
||||||
|
/>
|
||||||
|
<div class="chips">
|
||||||
|
<span *ngFor="let tag of activeFilters" class="chip">
|
||||||
|
{{ tag }} <button (click)="removeFilter(tag)">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul class="suggestions" *ngIf="suggestions.length">
|
||||||
|
<li *ngFor="let s of suggestions" (click)="addFilter(s.name)">{{ s.name }} ({{ s.image_count }})</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="images.length === 0 && !loading" class="empty-state">
|
||||||
|
<p>{{ activeFilters.length ? 'No images match these filters.' : 'No images yet. Upload your first!' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div
|
||||||
|
*ngFor="let img of images"
|
||||||
|
class="image-card"
|
||||||
|
(click)="router.navigate(['/images', img.id])"
|
||||||
|
>
|
||||||
|
<img [src]="imageService.getFileUrl(img.id)" [alt]="img.filename" loading="lazy" />
|
||||||
|
<div class="tag-row">
|
||||||
|
<span *ngFor="let tag of img.tags" class="chip small">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button *ngIf="hasMore" class="load-more" (click)="loadMore()">Load more</button>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
ui/src/app/not-found/not-found.component.ts
Normal file
16
ui/src/app/not-found/not-found.component.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-not-found',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink],
|
||||||
|
template: `
|
||||||
|
<div class="not-found">
|
||||||
|
<h2>404 — Page not found</h2>
|
||||||
|
<a routerLink="/">Back to library</a>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`.not-found { text-align: center; padding: 80px 16px; } a { color: #4a9eff; }`],
|
||||||
|
})
|
||||||
|
export class NotFoundComponent {}
|
||||||
41
ui/src/app/services/image.service.spec.ts
Normal file
41
ui/src/app/services/image.service.spec.ts
Normal file
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
64
ui/src/app/services/image.service.ts
Normal file
64
ui/src/app/services/image.service.ts
Normal file
@@ -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<ImageRecord> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
if (tags.length) {
|
||||||
|
form.append('tags', tags.join(','));
|
||||||
|
}
|
||||||
|
return this.http.post<ImageRecord>(`${this.base}/images`, form);
|
||||||
|
}
|
||||||
|
|
||||||
|
list(tagFilter: string[] = [], limit = 50, offset = 0): Observable<ImageListResponse> {
|
||||||
|
let params = new HttpParams().set('limit', limit).set('offset', offset);
|
||||||
|
if (tagFilter.length) {
|
||||||
|
params = params.set('tags', tagFilter.join(','));
|
||||||
|
}
|
||||||
|
return this.http.get<ImageListResponse>(`${this.base}/images`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): Observable<ImageRecord> {
|
||||||
|
return this.http.get<ImageRecord>(`${this.base}/images/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileUrl(id: string): string {
|
||||||
|
return `${this.base}/images/${id}/file`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTags(id: string, tags: string[]): Observable<ImageRecord> {
|
||||||
|
return this.http.patch<ImageRecord>(`${this.base}/images/${id}/tags`, { tags });
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${this.base}/images/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
ui/src/app/services/tag.service.spec.ts
Normal file
33
ui/src/app/services/tag.service.spec.ts
Normal file
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
31
ui/src/app/services/tag.service.ts
Normal file
31
ui/src/app/services/tag.service.ts
Normal file
@@ -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<TagListResponse> {
|
||||||
|
let params = new HttpParams().set('limit', limit).set('offset', offset);
|
||||||
|
if (prefix) {
|
||||||
|
params = params.set('q', prefix);
|
||||||
|
}
|
||||||
|
return this.http.get<TagListResponse>(`${this.base}/tags`, { params });
|
||||||
|
}
|
||||||
|
}
|
||||||
83
ui/src/app/upload/upload.component.spec.ts
Normal file
83
ui/src/app/upload/upload.component.spec.ts
Normal file
@@ -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<ImageService> = {}): jasmine.SpyObj<ImageService> {
|
||||||
|
return jasmine.createSpyObj<ImageService>('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();
|
||||||
|
});
|
||||||
|
});
|
||||||
133
ui/src/app/upload/upload.component.ts
Normal file
133
ui/src/app/upload/upload.component.ts
Normal file
@@ -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: `
|
||||||
|
<div class="upload-page">
|
||||||
|
<h1>Upload Image</h1>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="drop-zone"
|
||||||
|
[class.drag-over]="isDragOver"
|
||||||
|
(dragover)="onDragOver($event)"
|
||||||
|
(dragleave)="isDragOver = false"
|
||||||
|
(drop)="onDrop($event)"
|
||||||
|
(click)="fileInput.click()"
|
||||||
|
>
|
||||||
|
<p>{{ selectedFile ? selectedFile.name : 'Drag & drop or click to browse' }}</p>
|
||||||
|
<input #fileInput type="file" accept="image/*" hidden (change)="onFileChange($event)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tag-input" *ngIf="selectedFile">
|
||||||
|
<label>Tags (comma or space separated)</label>
|
||||||
|
<input [(ngModel)]="tagInput" placeholder="cat, funny, reaction" />
|
||||||
|
<div class="chips">
|
||||||
|
<span *ngFor="let tag of parseTagInput(tagInput)" class="chip">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button [disabled]="!selectedFile || uploading" (click)="submit()">
|
||||||
|
{{ uploading ? 'Uploading…' : 'Upload' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="toast" *ngIf="toastMessage">{{ toastMessage }}</p>
|
||||||
|
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
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<void> {
|
||||||
|
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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
12
ui/src/index.html
Normal file
12
ui/src/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Reactbin</title>
|
||||||
|
<base href="/">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5
ui/src/main.ts
Normal file
5
ui/src/main.ts
Normal file
@@ -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));
|
||||||
12
ui/src/styles.css
Normal file
12
ui/src/styles.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
9
ui/tsconfig.app.json
Normal file
9
ui/tsconfig.app.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"files": ["src/main.ts"],
|
||||||
|
"include": ["src/**/*.d.ts"]
|
||||||
|
}
|
||||||
29
ui/tsconfig.json
Normal file
29
ui/tsconfig.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
8
ui/tsconfig.spec.json
Normal file
8
ui/tsconfig.spec.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": ["jasmine"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user