From 52e7e3bbc2a5b9a20a50e49159238bd15009ad24 Mon Sep 17 00:00:00 2001 From: jlightner Date: Tue, 31 Mar 2026 02:34:12 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20remove=20review=20workflow=20=E2=80=94?= =?UTF-8?q?=20unused=20gate=20that=20blocked=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 773 key moments sat at 'pending' with 0 approved/edited/rejected. review_status was never checked by any public-facing query — all content was always visible regardless of review state. Removed: - backend/routers/review.py (10 endpoints) - backend/tests/test_review.py - frontend ReviewQueue, MomentDetail pages - frontend client.ts (review-only API client) - frontend ModeToggle, StatusBadge components - Review link from AdminDropdown, Moments link from pipeline rows - ReviewStatus, PageReviewStatus enums from models - review_mode config flag - review_status columns (migration 007) - ~80 lines of mode-toggle CSS Pipeline now always sets processing_status to 'published'. Migration 007 drops columns, enums, and migrates 'reviewed' → 'published'. --- alembic/versions/007_drop_review_columns.py | 30 ++ backend/config.py | 3 - backend/main.py | 3 +- backend/models.py | 26 - backend/pipeline/stages.py | 11 +- backend/routers/review.py | 375 --------------- backend/schemas.py | 56 --- backend/tests/test_pipeline.py | 5 +- backend/tests/test_review.py | 495 -------------------- frontend/src/App.css | 87 +--- frontend/src/App.tsx | 4 - frontend/src/api/client.ts | 193 -------- frontend/src/api/public-client.ts | 2 - frontend/src/components/AdminDropdown.tsx | 8 - frontend/src/components/ModeToggle.tsx | 59 --- frontend/src/components/StatusBadge.tsx | 19 - frontend/src/pages/AdminPipeline.tsx | 8 - frontend/src/pages/MomentDetail.tsx | 454 ------------------ frontend/src/pages/ReviewQueue.tsx | 189 -------- 19 files changed, 38 insertions(+), 1989 deletions(-) create mode 100644 alembic/versions/007_drop_review_columns.py delete mode 100644 backend/routers/review.py delete mode 100644 backend/tests/test_review.py delete mode 100644 frontend/src/api/client.ts delete mode 100644 frontend/src/components/ModeToggle.tsx delete mode 100644 frontend/src/components/StatusBadge.tsx delete mode 100644 frontend/src/pages/MomentDetail.tsx delete mode 100644 frontend/src/pages/ReviewQueue.tsx diff --git a/alembic/versions/007_drop_review_columns.py b/alembic/versions/007_drop_review_columns.py new file mode 100644 index 0000000..51a044a --- /dev/null +++ b/alembic/versions/007_drop_review_columns.py @@ -0,0 +1,30 @@ +"""Drop review_status columns and enums. + +Revision ID: 007_drop_review_columns +Revises: 006_debug_columns +""" +from alembic import op + +revision = "007_drop_review_columns" +down_revision = "006_debug_columns" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column("key_moments", "review_status") + op.drop_column("technique_pages", "review_status") + op.execute("DROP TYPE IF EXISTS review_status") + op.execute("DROP TYPE IF EXISTS page_review_status") + # Collapse 'reviewed' into 'published' for any existing rows + op.execute( + "UPDATE source_videos SET processing_status = 'published' " + "WHERE processing_status = 'reviewed'" + ) + + +def downgrade() -> None: + op.execute("CREATE TYPE review_status AS ENUM ('pending', 'approved', 'edited', 'rejected')") + op.execute("CREATE TYPE page_review_status AS ENUM ('draft', 'reviewed', 'published')") + op.add_column("key_moments", op.Column("review_status", op.Enum("pending", "approved", "edited", "rejected", name="review_status"), server_default="pending", nullable=False)) + op.add_column("technique_pages", op.Column("review_status", op.Enum("draft", "reviewed", "published", name="page_review_status"), server_default="draft", nullable=False)) diff --git a/backend/config.py b/backend/config.py index 60deede..6064212 100644 --- a/backend/config.py +++ b/backend/config.py @@ -59,9 +59,6 @@ class Settings(BaseSettings): # Prompt templates prompts_path: str = "./prompts" - # Review mode — when True, extracted moments go to review queue before publishing - review_mode: bool = True - # Debug mode — when True, pipeline captures full LLM prompts and responses debug_mode: bool = False diff --git a/backend/main.py b/backend/main.py index 7e93318..0f5ac1f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,7 +12,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from config import get_settings -from routers import creators, health, ingest, pipeline, reports, review, search, techniques, topics, videos +from routers import creators, health, ingest, pipeline, reports, search, techniques, topics, videos def _setup_logging() -> None: @@ -81,7 +81,6 @@ app.include_router(health.router) app.include_router(creators.router, prefix="/api/v1") app.include_router(ingest.router, prefix="/api/v1") app.include_router(pipeline.router, prefix="/api/v1") -app.include_router(review.router, prefix="/api/v1") app.include_router(reports.router, prefix="/api/v1") app.include_router(search.router, prefix="/api/v1") app.include_router(techniques.router, prefix="/api/v1") diff --git a/backend/models.py b/backend/models.py index bf8e8ba..ec71bb9 100644 --- a/backend/models.py +++ b/backend/models.py @@ -43,7 +43,6 @@ class ProcessingStatus(str, enum.Enum): pending = "pending" transcribed = "transcribed" extracted = "extracted" - reviewed = "reviewed" published = "published" @@ -55,14 +54,6 @@ class KeyMomentContentType(str, enum.Enum): workflow = "workflow" -class ReviewStatus(str, enum.Enum): - """Human review status for key moments.""" - pending = "pending" - approved = "approved" - edited = "edited" - rejected = "rejected" - - class SourceQuality(str, enum.Enum): """Derived source quality for technique pages.""" structured = "structured" @@ -70,13 +61,6 @@ class SourceQuality(str, enum.Enum): unstructured = "unstructured" -class PageReviewStatus(str, enum.Enum): - """Review lifecycle for technique pages.""" - draft = "draft" - reviewed = "reviewed" - published = "published" - - class RelationshipType(str, enum.Enum): """Types of links between technique pages.""" same_technique_other_creator = "same_technique_other_creator" @@ -197,11 +181,6 @@ class KeyMoment(Base): nullable=False, ) plugins: Mapped[list[str] | None] = mapped_column(ARRAY(String), nullable=True) - review_status: Mapped[ReviewStatus] = mapped_column( - Enum(ReviewStatus, name="review_status", create_constraint=True), - default=ReviewStatus.pending, - server_default="pending", - ) raw_transcript: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column( default=_now, server_default=func.now() @@ -237,11 +216,6 @@ class TechniquePage(Base): nullable=True, ) view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0") - review_status: Mapped[PageReviewStatus] = mapped_column( - Enum(PageReviewStatus, name="page_review_status", create_constraint=True), - default=PageReviewStatus.draft, - server_default="draft", - ) created_at: Mapped[datetime] = mapped_column( default=_now, server_default=func.now() ) diff --git a/backend/pipeline/stages.py b/backend/pipeline/stages.py index 6239ed3..1fef96e 100644 --- a/backend/pipeline/stages.py +++ b/backend/pipeline/stages.py @@ -687,7 +687,7 @@ def stage5_synthesis(self, video_id: str) -> str: each group into a TechniquePage, creates/updates page rows, and links KeyMoments to their TechniquePage. - Sets processing_status to 'reviewed' (or 'published' if review_mode is False). + Sets processing_status to 'published'. Returns the video_id for chain compatibility. """ @@ -867,10 +867,7 @@ def stage5_synthesis(self, video_id: str) -> str: m.technique_page_id = page.id # Update processing_status - if settings.review_mode: - video.processing_status = ProcessingStatus.reviewed - else: - video.processing_status = ProcessingStatus.published + video.processing_status = ProcessingStatus.published session.commit() elapsed = time.monotonic() - start @@ -1085,7 +1082,7 @@ def run_pipeline(video_id: str) -> str: stages that still need to run. For example: - pending/transcribed → stages 2, 3, 4, 5 - extracted → stages 4, 5 - - reviewed/published → no-op + - published → no-op Returns the video_id. """ @@ -1127,7 +1124,7 @@ def run_pipeline(video_id: str) -> str: stage5_synthesis.s(), stage6_embed_and_index.s(), ] - elif status in (ProcessingStatus.reviewed, ProcessingStatus.published): + elif status == ProcessingStatus.published: logger.info( "run_pipeline: video_id=%s already at status=%s, nothing to do.", video_id, status.value, diff --git a/backend/routers/review.py b/backend/routers/review.py deleted file mode 100644 index 53b4e8e..0000000 --- a/backend/routers/review.py +++ /dev/null @@ -1,375 +0,0 @@ -"""Review queue endpoints for Chrysopedia API. - -Provides admin review workflow: list queue, stats, approve, reject, -edit, split, merge key moments, and toggle review/auto mode via Redis. -""" - -import logging -import uuid -from typing import Annotated - -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import case, func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from config import get_settings -from database import get_session -from models import Creator, KeyMoment, KeyMomentContentType, ReviewStatus, SourceVideo -from redis_client import get_redis -from schemas import ( - KeyMomentRead, - MomentEditRequest, - MomentMergeRequest, - MomentSplitRequest, - ReviewModeResponse, - ReviewModeUpdate, - ReviewQueueItem, - ReviewQueueResponse, - ReviewStatsResponse, -) - -logger = logging.getLogger("chrysopedia.review") - -router = APIRouter(prefix="/review", tags=["review"]) - -REDIS_MODE_KEY = "chrysopedia:review_mode" - -VALID_STATUSES = {"pending", "approved", "edited", "rejected", "all"} - - -# ── Helpers ────────────────────────────────────────────────────────────────── - - -def _moment_to_queue_item( - moment: KeyMoment, video_filename: str, creator_name: str -) -> ReviewQueueItem: - """Convert a KeyMoment ORM instance + joined fields to a ReviewQueueItem.""" - data = KeyMomentRead.model_validate(moment).model_dump() - data["video_filename"] = video_filename - data["creator_name"] = creator_name - return ReviewQueueItem(**data) - - -# ── Endpoints ──────────────────────────────────────────────────────────────── - - -@router.get("/queue", response_model=ReviewQueueResponse) -async def list_queue( - status: Annotated[str, Query()] = "pending", - offset: Annotated[int, Query(ge=0)] = 0, - limit: Annotated[int, Query(ge=1, le=1000)] = 50, - db: AsyncSession = Depends(get_session), -) -> ReviewQueueResponse: - """List key moments in the review queue, filtered by status.""" - if status not in VALID_STATUSES: - raise HTTPException( - status_code=400, - detail=f"Invalid status filter '{status}'. Must be one of: {', '.join(sorted(VALID_STATUSES))}", - ) - - # Base query joining KeyMoment → SourceVideo → Creator - base = ( - select( - KeyMoment, - SourceVideo.filename.label("video_filename"), - Creator.name.label("creator_name"), - ) - .join(SourceVideo, KeyMoment.source_video_id == SourceVideo.id) - .join(Creator, SourceVideo.creator_id == Creator.id) - ) - - if status != "all": - base = base.where(KeyMoment.review_status == ReviewStatus(status)) - - # Count total matching rows - count_stmt = select(func.count()).select_from(base.subquery()) - total = (await db.execute(count_stmt)).scalar_one() - - # Fetch paginated results - stmt = base.order_by(KeyMoment.created_at.desc()).offset(offset).limit(limit) - rows = (await db.execute(stmt)).all() - - items = [ - _moment_to_queue_item(row.KeyMoment, row.video_filename, row.creator_name) - for row in rows - ] - - return ReviewQueueResponse(items=items, total=total, offset=offset, limit=limit) - - -@router.get("/stats", response_model=ReviewStatsResponse) -async def get_stats( - db: AsyncSession = Depends(get_session), -) -> ReviewStatsResponse: - """Return counts of key moments grouped by review status.""" - stmt = ( - select( - KeyMoment.review_status, - func.count().label("cnt"), - ) - .group_by(KeyMoment.review_status) - ) - result = await db.execute(stmt) - counts = {row.review_status.value: row.cnt for row in result.all()} - - return ReviewStatsResponse( - pending=counts.get("pending", 0), - approved=counts.get("approved", 0), - edited=counts.get("edited", 0), - rejected=counts.get("rejected", 0), - ) - - -@router.post("/moments/{moment_id}/approve", response_model=KeyMomentRead) -async def approve_moment( - moment_id: uuid.UUID, - db: AsyncSession = Depends(get_session), -) -> KeyMomentRead: - """Approve a key moment for publishing.""" - moment = await db.get(KeyMoment, moment_id) - if moment is None: - raise HTTPException( - status_code=404, - detail=f"Key moment {moment_id} not found", - ) - - moment.review_status = ReviewStatus.approved - await db.commit() - await db.refresh(moment) - - logger.info("Approved key moment %s", moment_id) - return KeyMomentRead.model_validate(moment) - - -@router.post("/moments/{moment_id}/reject", response_model=KeyMomentRead) -async def reject_moment( - moment_id: uuid.UUID, - db: AsyncSession = Depends(get_session), -) -> KeyMomentRead: - """Reject a key moment.""" - moment = await db.get(KeyMoment, moment_id) - if moment is None: - raise HTTPException( - status_code=404, - detail=f"Key moment {moment_id} not found", - ) - - moment.review_status = ReviewStatus.rejected - await db.commit() - await db.refresh(moment) - - logger.info("Rejected key moment %s", moment_id) - return KeyMomentRead.model_validate(moment) - - -@router.put("/moments/{moment_id}", response_model=KeyMomentRead) -async def edit_moment( - moment_id: uuid.UUID, - body: MomentEditRequest, - db: AsyncSession = Depends(get_session), -) -> KeyMomentRead: - """Update editable fields of a key moment and set status to edited.""" - moment = await db.get(KeyMoment, moment_id) - if moment is None: - raise HTTPException( - status_code=404, - detail=f"Key moment {moment_id} not found", - ) - - update_data = body.model_dump(exclude_unset=True) - # Convert content_type string to enum if provided - if "content_type" in update_data and update_data["content_type"] is not None: - try: - update_data["content_type"] = KeyMomentContentType(update_data["content_type"]) - except ValueError: - raise HTTPException( - status_code=400, - detail=f"Invalid content_type '{update_data['content_type']}'", - ) - - for field, value in update_data.items(): - setattr(moment, field, value) - - moment.review_status = ReviewStatus.edited - await db.commit() - await db.refresh(moment) - - logger.info("Edited key moment %s (fields: %s)", moment_id, list(update_data.keys())) - return KeyMomentRead.model_validate(moment) - - -@router.post("/moments/{moment_id}/split", response_model=list[KeyMomentRead]) -async def split_moment( - moment_id: uuid.UUID, - body: MomentSplitRequest, - db: AsyncSession = Depends(get_session), -) -> list[KeyMomentRead]: - """Split a key moment into two at the given timestamp.""" - moment = await db.get(KeyMoment, moment_id) - if moment is None: - raise HTTPException( - status_code=404, - detail=f"Key moment {moment_id} not found", - ) - - # Validate split_time is strictly between start_time and end_time - if body.split_time <= moment.start_time or body.split_time >= moment.end_time: - raise HTTPException( - status_code=400, - detail=( - f"split_time ({body.split_time}) must be strictly between " - f"start_time ({moment.start_time}) and end_time ({moment.end_time})" - ), - ) - - # Update original moment to [start_time, split_time) - original_end = moment.end_time - moment.end_time = body.split_time - moment.review_status = ReviewStatus.pending - - # Create new moment for [split_time, end_time] - new_moment = KeyMoment( - source_video_id=moment.source_video_id, - technique_page_id=moment.technique_page_id, - title=f"{moment.title} (split)", - summary=moment.summary, - start_time=body.split_time, - end_time=original_end, - content_type=moment.content_type, - plugins=moment.plugins, - review_status=ReviewStatus.pending, - raw_transcript=moment.raw_transcript, - ) - db.add(new_moment) - - await db.commit() - await db.refresh(moment) - await db.refresh(new_moment) - - logger.info( - "Split key moment %s at %.2f → original [%.2f, %.2f), new [%.2f, %.2f]", - moment_id, body.split_time, - moment.start_time, moment.end_time, - new_moment.start_time, new_moment.end_time, - ) - - return [ - KeyMomentRead.model_validate(moment), - KeyMomentRead.model_validate(new_moment), - ] - - -@router.post("/moments/{moment_id}/merge", response_model=KeyMomentRead) -async def merge_moments( - moment_id: uuid.UUID, - body: MomentMergeRequest, - db: AsyncSession = Depends(get_session), -) -> KeyMomentRead: - """Merge two key moments into one.""" - if moment_id == body.target_moment_id: - raise HTTPException( - status_code=400, - detail="Cannot merge a moment with itself", - ) - - source = await db.get(KeyMoment, moment_id) - if source is None: - raise HTTPException( - status_code=404, - detail=f"Key moment {moment_id} not found", - ) - - target = await db.get(KeyMoment, body.target_moment_id) - if target is None: - raise HTTPException( - status_code=404, - detail=f"Target key moment {body.target_moment_id} not found", - ) - - # Both must belong to the same source video - if source.source_video_id != target.source_video_id: - raise HTTPException( - status_code=400, - detail="Cannot merge moments from different source videos", - ) - - # Merge: combined summary, min start, max end - source.summary = f"{source.summary}\n\n{target.summary}" - source.start_time = min(source.start_time, target.start_time) - source.end_time = max(source.end_time, target.end_time) - source.review_status = ReviewStatus.pending - - # Delete target - await db.delete(target) - await db.commit() - await db.refresh(source) - - logger.info( - "Merged key moment %s with %s → [%.2f, %.2f]", - moment_id, body.target_moment_id, - source.start_time, source.end_time, - ) - - return KeyMomentRead.model_validate(source) - - - - -@router.get("/moments/{moment_id}", response_model=ReviewQueueItem) -async def get_moment( - moment_id: uuid.UUID, - db: AsyncSession = Depends(get_session), -) -> ReviewQueueItem: - """Get a single key moment by ID with video and creator info.""" - stmt = ( - select(KeyMoment, SourceVideo.file_path, Creator.name) - .join(SourceVideo, KeyMoment.source_video_id == SourceVideo.id) - .join(Creator, SourceVideo.creator_id == Creator.id) - .where(KeyMoment.id == moment_id) - ) - result = await db.execute(stmt) - row = result.one_or_none() - if row is None: - raise HTTPException(status_code=404, detail=f"Moment {moment_id} not found") - moment, file_path, creator_name = row - return _moment_to_queue_item(moment, file_path or "", creator_name) - -@router.get("/mode", response_model=ReviewModeResponse) -async def get_mode() -> ReviewModeResponse: - """Get the current review mode (review vs auto).""" - settings = get_settings() - try: - redis = await get_redis() - try: - value = await redis.get(REDIS_MODE_KEY) - if value is not None: - return ReviewModeResponse(review_mode=value.lower() == "true") - finally: - await redis.aclose() - except Exception as exc: - # Redis unavailable — fall back to config default - logger.warning("Redis unavailable for mode read, using config default: %s", exc) - - return ReviewModeResponse(review_mode=settings.review_mode) - - -@router.put("/mode", response_model=ReviewModeResponse) -async def set_mode( - body: ReviewModeUpdate, -) -> ReviewModeResponse: - """Set the review mode (review vs auto).""" - try: - redis = await get_redis() - try: - await redis.set(REDIS_MODE_KEY, str(body.review_mode)) - finally: - await redis.aclose() - except Exception as exc: - logger.error("Failed to set review mode in Redis: %s", exc) - raise HTTPException( - status_code=503, - detail=f"Redis unavailable: {exc}", - ) - - logger.info("Review mode set to %s", body.review_mode) - return ReviewModeResponse(review_mode=body.review_mode) diff --git a/backend/schemas.py b/backend/schemas.py index e7d7320..de0d716 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -109,7 +109,6 @@ class KeyMomentRead(KeyMomentBase): id: uuid.UUID source_video_id: uuid.UUID technique_page_id: uuid.UUID | None = None - review_status: str = "pending" created_at: datetime updated_at: datetime @@ -139,7 +138,6 @@ class TechniquePageRead(TechniquePageBase): creator_slug: str = "" source_quality: str | None = None view_count: int = 0 - review_status: str = "draft" created_at: datetime updated_at: datetime @@ -200,60 +198,6 @@ class PaginatedResponse(BaseModel): limit: int = 50 -# ── Review Queue ───────────────────────────────────────────────────────────── - -class ReviewQueueItem(KeyMomentRead): - """Key moment enriched with source video and creator info for review UI.""" - video_filename: str - creator_name: str - - -class ReviewQueueResponse(BaseModel): - """Paginated response for the review queue.""" - items: list[ReviewQueueItem] = Field(default_factory=list) - total: int = 0 - offset: int = 0 - limit: int = 50 - - -class ReviewStatsResponse(BaseModel): - """Counts of key moments grouped by review status.""" - pending: int = 0 - approved: int = 0 - edited: int = 0 - rejected: int = 0 - - -class MomentEditRequest(BaseModel): - """Editable fields for a key moment.""" - title: str | None = None - summary: str | None = None - start_time: float | None = None - end_time: float | None = None - content_type: str | None = None - plugins: list[str] | None = None - - -class MomentSplitRequest(BaseModel): - """Request to split a moment at a given timestamp.""" - split_time: float - - -class MomentMergeRequest(BaseModel): - """Request to merge two moments.""" - target_moment_id: uuid.UUID - - -class ReviewModeResponse(BaseModel): - """Current review mode state.""" - review_mode: bool - - -class ReviewModeUpdate(BaseModel): - """Request to update the review mode.""" - review_mode: bool - - # ── Search ─────────────────────────────────────────────────────────────────── class SearchResultItem(BaseModel): diff --git a/backend/tests/test_pipeline.py b/backend/tests/test_pipeline.py index 9e5d00a..4641a78 100644 --- a/backend/tests/test_pipeline.py +++ b/backend/tests/test_pipeline.py @@ -312,7 +312,6 @@ def test_stage4_classification_assigns_tags( s.llm_fallback_url = "http://mock:11434/v1" s.llm_fallback_model = "test-model" s.database_url = TEST_DATABASE_URL_SYNC.replace("psycopg2", "asyncpg") - s.review_mode = True mock_settings.return_value = s mock_tags.return_value = { @@ -391,7 +390,6 @@ def test_stage5_synthesis_creates_technique_pages( s.llm_fallback_url = "http://mock:11434/v1" s.llm_fallback_model = "test-model" s.database_url = TEST_DATABASE_URL_SYNC.replace("psycopg2", "asyncpg") - s.review_mode = True mock_settings.return_value = s mock_tags.return_value = { @@ -461,7 +459,7 @@ def test_stage5_synthesis_creates_technique_pages( video = session.execute( select(SourceVideo).where(SourceVideo.id == video_id) ).scalar_one() - assert video.processing_status == ProcessingStatus.reviewed + assert video.processing_status == ProcessingStatus.published finally: session.close() @@ -521,7 +519,6 @@ def test_stage6_embeds_and_upserts_to_qdrant( s.llm_fallback_url = "http://mock:11434/v1" s.llm_fallback_model = "test-model" s.database_url = TEST_DATABASE_URL_SYNC.replace("psycopg2", "asyncpg") - s.review_mode = True s.embedding_api_url = "http://mock:11434/v1" s.embedding_model = "test-embed" s.embedding_dimensions = 768 diff --git a/backend/tests/test_review.py b/backend/tests/test_review.py deleted file mode 100644 index f0b0d2a..0000000 --- a/backend/tests/test_review.py +++ /dev/null @@ -1,495 +0,0 @@ -"""Integration tests for the review queue endpoints. - -Tests run against a real PostgreSQL test database via httpx.AsyncClient. -Redis is mocked for mode toggle tests. -""" - -import uuid -from unittest.mock import AsyncMock, patch - -import pytest -import pytest_asyncio -from httpx import AsyncClient -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - -from models import ( - ContentType, - Creator, - KeyMoment, - KeyMomentContentType, - ProcessingStatus, - ReviewStatus, - SourceVideo, -) - - -# ── Helpers ────────────────────────────────────────────────────────────────── - -QUEUE_URL = "/api/v1/review/queue" -STATS_URL = "/api/v1/review/stats" -MODE_URL = "/api/v1/review/mode" - - -def _moment_url(moment_id: str, action: str = "") -> str: - """Build a moment action URL.""" - base = f"/api/v1/review/moments/{moment_id}" - return f"{base}/{action}" if action else base - - -async def _seed_creator_and_video(db_engine) -> dict: - """Seed a creator and source video, return their IDs.""" - session_factory = async_sessionmaker( - db_engine, class_=AsyncSession, expire_on_commit=False - ) - async with session_factory() as session: - creator = Creator( - name="TestCreator", - slug="test-creator", - folder_name="TestCreator", - ) - session.add(creator) - await session.flush() - - video = SourceVideo( - creator_id=creator.id, - filename="test-video.mp4", - file_path="TestCreator/test-video.mp4", - duration_seconds=600, - content_type=ContentType.tutorial, - processing_status=ProcessingStatus.extracted, - ) - session.add(video) - await session.flush() - - result = { - "creator_id": creator.id, - "creator_name": creator.name, - "video_id": video.id, - "video_filename": video.filename, - } - await session.commit() - return result - - -async def _seed_moment( - db_engine, - video_id: uuid.UUID, - title: str = "Test Moment", - summary: str = "A test key moment", - start_time: float = 10.0, - end_time: float = 30.0, - review_status: ReviewStatus = ReviewStatus.pending, -) -> uuid.UUID: - """Seed a single key moment and return its ID.""" - session_factory = async_sessionmaker( - db_engine, class_=AsyncSession, expire_on_commit=False - ) - async with session_factory() as session: - moment = KeyMoment( - source_video_id=video_id, - title=title, - summary=summary, - start_time=start_time, - end_time=end_time, - content_type=KeyMomentContentType.technique, - review_status=review_status, - ) - session.add(moment) - await session.commit() - return moment.id - - -async def _seed_second_video(db_engine, creator_id: uuid.UUID) -> uuid.UUID: - """Seed a second video for cross-video merge tests.""" - session_factory = async_sessionmaker( - db_engine, class_=AsyncSession, expire_on_commit=False - ) - async with session_factory() as session: - video = SourceVideo( - creator_id=creator_id, - filename="other-video.mp4", - file_path="TestCreator/other-video.mp4", - duration_seconds=300, - content_type=ContentType.tutorial, - processing_status=ProcessingStatus.extracted, - ) - session.add(video) - await session.commit() - return video.id - - -# ── Queue listing tests ───────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_list_queue_empty(client: AsyncClient): - """Queue returns empty list when no moments exist.""" - resp = await client.get(QUEUE_URL) - assert resp.status_code == 200 - data = resp.json() - assert data["items"] == [] - assert data["total"] == 0 - - -@pytest.mark.asyncio -async def test_list_queue_with_moments(client: AsyncClient, db_engine): - """Queue returns moments enriched with video filename and creator name.""" - seed = await _seed_creator_and_video(db_engine) - await _seed_moment(db_engine, seed["video_id"], title="EQ Basics") - - resp = await client.get(QUEUE_URL) - assert resp.status_code == 200 - data = resp.json() - assert data["total"] == 1 - item = data["items"][0] - assert item["title"] == "EQ Basics" - assert item["video_filename"] == seed["video_filename"] - assert item["creator_name"] == seed["creator_name"] - assert item["review_status"] == "pending" - - -@pytest.mark.asyncio -async def test_list_queue_filter_by_status(client: AsyncClient, db_engine): - """Queue filters correctly by status query parameter.""" - seed = await _seed_creator_and_video(db_engine) - await _seed_moment(db_engine, seed["video_id"], title="Pending One") - await _seed_moment( - db_engine, seed["video_id"], title="Approved One", - review_status=ReviewStatus.approved, - ) - await _seed_moment( - db_engine, seed["video_id"], title="Rejected One", - review_status=ReviewStatus.rejected, - ) - - # Default filter: pending - resp = await client.get(QUEUE_URL) - assert resp.json()["total"] == 1 - assert resp.json()["items"][0]["title"] == "Pending One" - - # Approved - resp = await client.get(QUEUE_URL, params={"status": "approved"}) - assert resp.json()["total"] == 1 - assert resp.json()["items"][0]["title"] == "Approved One" - - # All - resp = await client.get(QUEUE_URL, params={"status": "all"}) - assert resp.json()["total"] == 3 - - -# ── Stats tests ────────────────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_stats_counts(client: AsyncClient, db_engine): - """Stats returns correct counts per review status.""" - seed = await _seed_creator_and_video(db_engine) - await _seed_moment(db_engine, seed["video_id"], review_status=ReviewStatus.pending) - await _seed_moment(db_engine, seed["video_id"], review_status=ReviewStatus.pending) - await _seed_moment(db_engine, seed["video_id"], review_status=ReviewStatus.approved) - await _seed_moment(db_engine, seed["video_id"], review_status=ReviewStatus.rejected) - - resp = await client.get(STATS_URL) - assert resp.status_code == 200 - data = resp.json() - assert data["pending"] == 2 - assert data["approved"] == 1 - assert data["edited"] == 0 - assert data["rejected"] == 1 - - -# ── Approve tests ──────────────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_approve_moment(client: AsyncClient, db_engine): - """Approve sets review_status to approved.""" - seed = await _seed_creator_and_video(db_engine) - moment_id = await _seed_moment(db_engine, seed["video_id"]) - - resp = await client.post(_moment_url(str(moment_id), "approve")) - assert resp.status_code == 200 - assert resp.json()["review_status"] == "approved" - - -@pytest.mark.asyncio -async def test_approve_nonexistent_moment(client: AsyncClient): - """Approve returns 404 for nonexistent moment.""" - fake_id = str(uuid.uuid4()) - resp = await client.post(_moment_url(fake_id, "approve")) - assert resp.status_code == 404 - - -# ── Reject tests ───────────────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_reject_moment(client: AsyncClient, db_engine): - """Reject sets review_status to rejected.""" - seed = await _seed_creator_and_video(db_engine) - moment_id = await _seed_moment(db_engine, seed["video_id"]) - - resp = await client.post(_moment_url(str(moment_id), "reject")) - assert resp.status_code == 200 - assert resp.json()["review_status"] == "rejected" - - -@pytest.mark.asyncio -async def test_reject_nonexistent_moment(client: AsyncClient): - """Reject returns 404 for nonexistent moment.""" - fake_id = str(uuid.uuid4()) - resp = await client.post(_moment_url(fake_id, "reject")) - assert resp.status_code == 404 - - -# ── Edit tests ─────────────────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_edit_moment(client: AsyncClient, db_engine): - """Edit updates fields and sets review_status to edited.""" - seed = await _seed_creator_and_video(db_engine) - moment_id = await _seed_moment(db_engine, seed["video_id"], title="Original Title") - - resp = await client.put( - _moment_url(str(moment_id)), - json={"title": "Updated Title", "summary": "New summary"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["title"] == "Updated Title" - assert data["summary"] == "New summary" - assert data["review_status"] == "edited" - - -@pytest.mark.asyncio -async def test_edit_nonexistent_moment(client: AsyncClient): - """Edit returns 404 for nonexistent moment.""" - fake_id = str(uuid.uuid4()) - resp = await client.put( - _moment_url(fake_id), - json={"title": "Won't Work"}, - ) - assert resp.status_code == 404 - - -# ── Split tests ────────────────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_split_moment(client: AsyncClient, db_engine): - """Split creates two moments with correct timestamps.""" - seed = await _seed_creator_and_video(db_engine) - moment_id = await _seed_moment( - db_engine, seed["video_id"], - title="Full Moment", start_time=10.0, end_time=30.0, - ) - - resp = await client.post( - _moment_url(str(moment_id), "split"), - json={"split_time": 20.0}, - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data) == 2 - - # First (original): [10.0, 20.0) - assert data[0]["start_time"] == 10.0 - assert data[0]["end_time"] == 20.0 - - # Second (new): [20.0, 30.0] - assert data[1]["start_time"] == 20.0 - assert data[1]["end_time"] == 30.0 - assert "(split)" in data[1]["title"] - - -@pytest.mark.asyncio -async def test_split_invalid_time_below_start(client: AsyncClient, db_engine): - """Split returns 400 when split_time is at or below start_time.""" - seed = await _seed_creator_and_video(db_engine) - moment_id = await _seed_moment( - db_engine, seed["video_id"], start_time=10.0, end_time=30.0, - ) - - resp = await client.post( - _moment_url(str(moment_id), "split"), - json={"split_time": 10.0}, - ) - assert resp.status_code == 400 - - -@pytest.mark.asyncio -async def test_split_invalid_time_above_end(client: AsyncClient, db_engine): - """Split returns 400 when split_time is at or above end_time.""" - seed = await _seed_creator_and_video(db_engine) - moment_id = await _seed_moment( - db_engine, seed["video_id"], start_time=10.0, end_time=30.0, - ) - - resp = await client.post( - _moment_url(str(moment_id), "split"), - json={"split_time": 30.0}, - ) - assert resp.status_code == 400 - - -@pytest.mark.asyncio -async def test_split_nonexistent_moment(client: AsyncClient): - """Split returns 404 for nonexistent moment.""" - fake_id = str(uuid.uuid4()) - resp = await client.post( - _moment_url(fake_id, "split"), - json={"split_time": 20.0}, - ) - assert resp.status_code == 404 - - -# ── Merge tests ────────────────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_merge_moments(client: AsyncClient, db_engine): - """Merge combines two moments: combined summary, min start, max end, target deleted.""" - seed = await _seed_creator_and_video(db_engine) - m1_id = await _seed_moment( - db_engine, seed["video_id"], - title="First", summary="Summary A", - start_time=10.0, end_time=20.0, - ) - m2_id = await _seed_moment( - db_engine, seed["video_id"], - title="Second", summary="Summary B", - start_time=25.0, end_time=35.0, - ) - - resp = await client.post( - _moment_url(str(m1_id), "merge"), - json={"target_moment_id": str(m2_id)}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["start_time"] == 10.0 - assert data["end_time"] == 35.0 - assert "Summary A" in data["summary"] - assert "Summary B" in data["summary"] - - # Target should be deleted — reject should 404 - resp2 = await client.post(_moment_url(str(m2_id), "reject")) - assert resp2.status_code == 404 - - -@pytest.mark.asyncio -async def test_merge_different_videos(client: AsyncClient, db_engine): - """Merge returns 400 when moments are from different source videos.""" - seed = await _seed_creator_and_video(db_engine) - m1_id = await _seed_moment(db_engine, seed["video_id"], title="Video 1 moment") - - other_video_id = await _seed_second_video(db_engine, seed["creator_id"]) - m2_id = await _seed_moment(db_engine, other_video_id, title="Video 2 moment") - - resp = await client.post( - _moment_url(str(m1_id), "merge"), - json={"target_moment_id": str(m2_id)}, - ) - assert resp.status_code == 400 - assert "different source videos" in resp.json()["detail"] - - -@pytest.mark.asyncio -async def test_merge_with_self(client: AsyncClient, db_engine): - """Merge returns 400 when trying to merge a moment with itself.""" - seed = await _seed_creator_and_video(db_engine) - m_id = await _seed_moment(db_engine, seed["video_id"]) - - resp = await client.post( - _moment_url(str(m_id), "merge"), - json={"target_moment_id": str(m_id)}, - ) - assert resp.status_code == 400 - assert "itself" in resp.json()["detail"] - - -@pytest.mark.asyncio -async def test_merge_nonexistent_target(client: AsyncClient, db_engine): - """Merge returns 404 when target moment does not exist.""" - seed = await _seed_creator_and_video(db_engine) - m_id = await _seed_moment(db_engine, seed["video_id"]) - - resp = await client.post( - _moment_url(str(m_id), "merge"), - json={"target_moment_id": str(uuid.uuid4())}, - ) - assert resp.status_code == 404 - - -@pytest.mark.asyncio -async def test_merge_nonexistent_source(client: AsyncClient): - """Merge returns 404 when source moment does not exist.""" - fake_id = str(uuid.uuid4()) - resp = await client.post( - _moment_url(fake_id, "merge"), - json={"target_moment_id": str(uuid.uuid4())}, - ) - assert resp.status_code == 404 - - -# ── Mode toggle tests ─────────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_get_mode_default(client: AsyncClient): - """Get mode returns config default when Redis has no value.""" - mock_redis = AsyncMock() - mock_redis.get = AsyncMock(return_value=None) - mock_redis.aclose = AsyncMock() - - with patch("routers.review.get_redis", return_value=mock_redis): - resp = await client.get(MODE_URL) - assert resp.status_code == 200 - # Default from config is True - assert resp.json()["review_mode"] is True - - -@pytest.mark.asyncio -async def test_set_mode(client: AsyncClient): - """Set mode writes to Redis and returns the new value.""" - mock_redis = AsyncMock() - mock_redis.set = AsyncMock() - mock_redis.aclose = AsyncMock() - - with patch("routers.review.get_redis", return_value=mock_redis): - resp = await client.put(MODE_URL, json={"review_mode": False}) - assert resp.status_code == 200 - assert resp.json()["review_mode"] is False - mock_redis.set.assert_called_once_with("chrysopedia:review_mode", "False") - - -@pytest.mark.asyncio -async def test_get_mode_from_redis(client: AsyncClient): - """Get mode reads the value stored in Redis.""" - mock_redis = AsyncMock() - mock_redis.get = AsyncMock(return_value="False") - mock_redis.aclose = AsyncMock() - - with patch("routers.review.get_redis", return_value=mock_redis): - resp = await client.get(MODE_URL) - assert resp.status_code == 200 - assert resp.json()["review_mode"] is False - - -@pytest.mark.asyncio -async def test_get_mode_redis_error_fallback(client: AsyncClient): - """Get mode falls back to config default when Redis is unavailable.""" - with patch("routers.review.get_redis", side_effect=ConnectionError("Redis down")): - resp = await client.get(MODE_URL) - assert resp.status_code == 200 - # Falls back to config default (True) - assert resp.json()["review_mode"] is True - - -@pytest.mark.asyncio -async def test_set_mode_redis_error(client: AsyncClient): - """Set mode returns 503 when Redis is unavailable.""" - with patch("routers.review.get_redis", side_effect=ConnectionError("Redis down")): - resp = await client.put(MODE_URL, json={"review_mode": False}) - assert resp.status_code == 503 diff --git a/frontend/src/App.css b/frontend/src/App.css index b8b30d3..3c9e3fb 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -50,9 +50,7 @@ --color-btn-reject: #dc2626; --color-btn-reject-hover: #b91c1c; - /* Mode toggle (green/amber work on dark) */ - --color-toggle-review: #10b981; - --color-toggle-auto: #f59e0b; + /* Toggle colors */ --color-toggle-track: #6b7280; --color-toggle-track-active: #059669; --color-toggle-thumb: #fff; @@ -478,73 +476,6 @@ a.app-footer__repo:hover { /* ── Mode toggle ──────────────────────────────────────────────────────────── */ -.mode-toggle { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.8125rem; -} - -.mode-toggle__dot { - display: inline-block; - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; -} - -.mode-toggle__dot--review { - background: var(--color-toggle-review); -} - -.mode-toggle__dot--auto { - background: var(--color-toggle-auto); -} - -.mode-toggle__label { - color: var(--color-text-on-header-label); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 6rem; -} - -.mode-toggle__switch { - position: relative; - width: 2.5rem; - height: 1.25rem; - background: var(--color-toggle-track); - border: none; - border-radius: 9999px; - cursor: pointer; - transition: background 0.2s; - flex-shrink: 0; -} - -.mode-toggle__switch--active { - background: var(--color-toggle-track-active); -} - -.mode-toggle__switch::after { - content: ""; - position: absolute; - top: 0.125rem; - left: 0.125rem; - width: 1rem; - height: 1rem; - background: var(--color-toggle-thumb); - border-radius: 50%; - transition: transform 0.2s; -} - -.mode-toggle__switch--active::after { - transform: translateX(1.25rem); -} - -.mode-toggle__switch:disabled { - opacity: 0.5; - cursor: not-allowed; -} - /* ── Debug Mode Toggle ────────────────────────────────────────────────────── */ .debug-toggle { @@ -572,7 +503,7 @@ a.app-footer__repo:hover { } .debug-toggle__switch--active { - background: var(--color-toggle-review); + background: #10b981; } .debug-toggle__switch::after { @@ -2818,20 +2749,6 @@ a.app-footer__repo:hover { white-space: nowrap; } -.pipeline-video__review-link { - color: var(--color-accent); - font-size: 0.75rem; - text-decoration: none; - white-space: nowrap; - opacity: 0.7; - transition: opacity 0.15s; -} - -.pipeline-video__review-link:hover { - opacity: 1; - text-decoration: underline; -} - .pipeline-video__actions { display: flex; gap: 0.375rem; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ca81290..e92edb1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,8 +5,6 @@ import TechniquePage from "./pages/TechniquePage"; import CreatorsBrowse from "./pages/CreatorsBrowse"; import CreatorDetail from "./pages/CreatorDetail"; import TopicsBrowse from "./pages/TopicsBrowse"; -import ReviewQueue from "./pages/ReviewQueue"; -import MomentDetail from "./pages/MomentDetail"; import AdminReports from "./pages/AdminReports"; import AdminPipeline from "./pages/AdminPipeline"; import AdminDropdown from "./components/AdminDropdown"; @@ -42,8 +40,6 @@ export default function App() { } /> {/* Admin routes */} - } /> - } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts deleted file mode 100644 index 1dd62da..0000000 --- a/frontend/src/api/client.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Typed API client for Chrysopedia review queue endpoints. - * - * All functions use fetch() with JSON handling and throw on non-OK responses. - * Base URL is empty so requests go through the Vite dev proxy or nginx in prod. - */ - -// ── Types ─────────────────────────────────────────────────────────────────── - -export interface KeyMomentRead { - id: string; - source_video_id: string; - technique_page_id: string | null; - title: string; - summary: string; - start_time: number; - end_time: number; - content_type: string; - plugins: string[] | null; - raw_transcript: string | null; - review_status: string; - created_at: string; - updated_at: string; -} - -export interface ReviewQueueItem extends KeyMomentRead { - video_filename: string; - creator_name: string; -} - -export interface ReviewQueueResponse { - items: ReviewQueueItem[]; - total: number; - offset: number; - limit: number; -} - -export interface ReviewStatsResponse { - pending: number; - approved: number; - edited: number; - rejected: number; -} - -export interface ReviewModeResponse { - review_mode: boolean; -} - -export interface MomentEditRequest { - title?: string; - summary?: string; - start_time?: number; - end_time?: number; - content_type?: string; - plugins?: string[]; -} - -export interface MomentSplitRequest { - split_time: number; -} - -export interface MomentMergeRequest { - target_moment_id: string; -} - -export interface QueueParams { - status?: string; - offset?: number; - limit?: number; -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -const BASE = "/api/v1/review"; - -class ApiError extends Error { - constructor( - public status: number, - public detail: string, - ) { - super(`API ${status}: ${detail}`); - this.name = "ApiError"; - } -} - -async function request(url: string, init?: RequestInit): Promise { - const res = await fetch(url, { - ...init, - headers: { - "Content-Type": "application/json", - ...init?.headers, - }, - }); - - if (!res.ok) { - let detail = res.statusText; - try { - const body = await res.json(); - detail = body.detail ?? detail; - } catch { - // body not JSON — keep statusText - } - throw new ApiError(res.status, detail); - } - - return res.json() as Promise; -} - -// ── Queue ──────────────────────────────────────────────────────────────────── - -export async function fetchQueue( - params: QueueParams = {}, -): Promise { - const qs = new URLSearchParams(); - if (params.status) qs.set("status", params.status); - if (params.offset !== undefined) qs.set("offset", String(params.offset)); - if (params.limit !== undefined) qs.set("limit", String(params.limit)); - const query = qs.toString(); - return request( - `${BASE}/queue${query ? `?${query}` : ""}`, - ); -} - -export async function fetchMoment( - momentId: string, -): Promise { - return request(`${BASE}/moments/${momentId}`); -} - -export async function fetchStats(): Promise { - return request(`${BASE}/stats`); -} - -// ── Actions ────────────────────────────────────────────────────────────────── - -export async function approveMoment(id: string): Promise { - return request(`${BASE}/moments/${id}/approve`, { - method: "POST", - }); -} - -export async function rejectMoment(id: string): Promise { - return request(`${BASE}/moments/${id}/reject`, { - method: "POST", - }); -} - -export async function editMoment( - id: string, - data: MomentEditRequest, -): Promise { - return request(`${BASE}/moments/${id}`, { - method: "PUT", - body: JSON.stringify(data), - }); -} - -export async function splitMoment( - id: string, - splitTime: number, -): Promise { - const body: MomentSplitRequest = { split_time: splitTime }; - return request(`${BASE}/moments/${id}/split`, { - method: "POST", - body: JSON.stringify(body), - }); -} - -export async function mergeMoments( - id: string, - targetId: string, -): Promise { - const body: MomentMergeRequest = { target_moment_id: targetId }; - return request(`${BASE}/moments/${id}/merge`, { - method: "POST", - body: JSON.stringify(body), - }); -} - -// ── Mode ───────────────────────────────────────────────────────────────────── - -export async function getReviewMode(): Promise { - return request(`${BASE}/mode`); -} - -export async function setReviewMode( - enabled: boolean, -): Promise { - return request(`${BASE}/mode`, { - method: "PUT", - body: JSON.stringify({ review_mode: enabled }), - }); -} diff --git a/frontend/src/api/public-client.ts b/frontend/src/api/public-client.ts index 247a93e..80c6cdb 100644 --- a/frontend/src/api/public-client.ts +++ b/frontend/src/api/public-client.ts @@ -63,7 +63,6 @@ export interface TechniquePageDetail { creator_id: string; source_quality: string | null; view_count: number; - review_status: string; created_at: string; updated_at: string; key_moments: KeyMomentSummary[]; @@ -102,7 +101,6 @@ export interface TechniqueListItem { creator_slug: string; source_quality: string | null; view_count: number; - review_status: string; created_at: string; updated_at: string; } diff --git a/frontend/src/components/AdminDropdown.tsx b/frontend/src/components/AdminDropdown.tsx index f626c65..eb8d8c0 100644 --- a/frontend/src/components/AdminDropdown.tsx +++ b/frontend/src/components/AdminDropdown.tsx @@ -42,14 +42,6 @@ export default function AdminDropdown() { {open && (
- setOpen(false)} - > - Review - (null); - const [toggling, setToggling] = useState(false); - - useEffect(() => { - let cancelled = false; - getReviewMode() - .then((res) => { - if (!cancelled) setReviewModeState(res.review_mode); - }) - .catch(() => { - // silently fail — mode indicator will just stay hidden - }); - return () => { cancelled = true; }; - }, []); - - async function handleToggle() { - if (reviewMode === null || toggling) return; - setToggling(true); - try { - const res = await setReviewMode(!reviewMode); - setReviewModeState(res.review_mode); - } catch { - // swallow — leave previous state - } finally { - setToggling(false); - } - } - - if (reviewMode === null) return null; - - return ( -
- - - {reviewMode ? "Review Mode" : "Auto Mode"} - -
- ); -} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx deleted file mode 100644 index 8f71e76..0000000 --- a/frontend/src/components/StatusBadge.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Reusable status badge with color coding. - * - * Maps review_status values to colored pill shapes: - * pending → amber, approved → green, edited → blue, rejected → red - */ - -interface StatusBadgeProps { - status: string; -} - -export default function StatusBadge({ status }: StatusBadgeProps) { - const normalized = status.toLowerCase(); - return ( - - {normalized} - - ); -} diff --git a/frontend/src/pages/AdminPipeline.tsx b/frontend/src/pages/AdminPipeline.tsx index fed5cf0..8dfa7da 100644 --- a/frontend/src/pages/AdminPipeline.tsx +++ b/frontend/src/pages/AdminPipeline.tsx @@ -613,14 +613,6 @@ export default function AdminPipeline() { {formatDate(video.last_event_at)} - e.stopPropagation()} - > - → Moments -
e.stopPropagation()}> diff --git a/frontend/src/pages/MomentDetail.tsx b/frontend/src/pages/MomentDetail.tsx deleted file mode 100644 index c21f07e..0000000 --- a/frontend/src/pages/MomentDetail.tsx +++ /dev/null @@ -1,454 +0,0 @@ -/** - * Moment review detail page. - * - * Displays full moment data with action buttons: - * - Approve / Reject → navigate back to queue - * - Edit → inline edit mode for title, summary, content_type - * - Split → dialog with timestamp input - * - Merge → dialog with moment selector - */ - -import { useCallback, useEffect, useState } from "react"; -import { useParams, useNavigate, Link } from "react-router-dom"; -import { - fetchMoment, - fetchQueue, - approveMoment, - rejectMoment, - editMoment, - splitMoment, - mergeMoments, - type ReviewQueueItem, -} from "../api/client"; -import StatusBadge from "../components/StatusBadge"; - -function formatTime(seconds: number): string { - const m = Math.floor(seconds / 60); - const s = Math.floor(seconds % 60); - return `${m}:${s.toString().padStart(2, "0")}`; -} - -export default function MomentDetail() { - const { momentId } = useParams<{ momentId: string }>(); - const navigate = useNavigate(); - - // ── Data state ── - const [moment, setMoment] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [actionError, setActionError] = useState(null); - const [acting, setActing] = useState(false); - - // ── Edit state ── - const [editing, setEditing] = useState(false); - const [editTitle, setEditTitle] = useState(""); - const [editSummary, setEditSummary] = useState(""); - const [editContentType, setEditContentType] = useState(""); - - // ── Split state ── - const [showSplit, setShowSplit] = useState(false); - const [splitTime, setSplitTime] = useState(""); - - // ── Merge state ── - const [showMerge, setShowMerge] = useState(false); - const [mergeCandidates, setMergeCandidates] = useState([]); - const [mergeTargetId, setMergeTargetId] = useState(""); - - const loadMoment = useCallback(async () => { - if (!momentId) return; - setLoading(true); - setError(null); - try { - // Fetch all moments and find the one matching our ID - const found = await fetchMoment(momentId); - setMoment(found); - setEditTitle(found.title); - setEditSummary(found.summary); - setEditContentType(found.content_type); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load moment"); - } finally { - setLoading(false); - } - }, [momentId]); - - useEffect(() => { - void loadMoment(); - }, [loadMoment]); - - // ── Action handlers ── - - async function handleApprove() { - if (!momentId || acting) return; - setActing(true); - setActionError(null); - try { - await approveMoment(momentId); - navigate("/admin/review"); - } catch (err) { - setActionError(err instanceof Error ? err.message : "Approve failed"); - } finally { - setActing(false); - } - } - - async function handleReject() { - if (!momentId || acting) return; - setActing(true); - setActionError(null); - try { - await rejectMoment(momentId); - navigate("/admin/review"); - } catch (err) { - setActionError(err instanceof Error ? err.message : "Reject failed"); - } finally { - setActing(false); - } - } - - function startEdit() { - if (!moment) return; - setEditTitle(moment.title); - setEditSummary(moment.summary); - setEditContentType(moment.content_type); - setEditing(true); - setActionError(null); - } - - async function handleEditSave() { - if (!momentId || acting) return; - setActing(true); - setActionError(null); - try { - await editMoment(momentId, { - title: editTitle, - summary: editSummary, - content_type: editContentType, - }); - setEditing(false); - await loadMoment(); - } catch (err) { - setActionError(err instanceof Error ? err.message : "Edit failed"); - } finally { - setActing(false); - } - } - - function openSplitDialog() { - if (!moment) return; - setSplitTime(""); - setShowSplit(true); - setActionError(null); - } - - async function handleSplit() { - if (!momentId || !moment || acting) return; - const t = parseFloat(splitTime); - if (isNaN(t) || t <= moment.start_time || t >= moment.end_time) { - setActionError( - `Split time must be between ${formatTime(moment.start_time)} and ${formatTime(moment.end_time)}` - ); - return; - } - setActing(true); - setActionError(null); - try { - await splitMoment(momentId, t); - setShowSplit(false); - navigate("/admin/review"); - } catch (err) { - setActionError(err instanceof Error ? err.message : "Split failed"); - } finally { - setActing(false); - } - } - - async function openMergeDialog() { - if (!moment) return; - setShowMerge(true); - setMergeTargetId(""); - setActionError(null); - try { - // Load moments from the same video for merge candidates - const res = await fetchQueue({ limit: 100 }); - const candidates = res.items.filter( - (m) => m.source_video_id === moment.source_video_id && m.id !== moment.id - ); - setMergeCandidates(candidates); - } catch { - setMergeCandidates([]); - } - } - - async function handleMerge() { - if (!momentId || !mergeTargetId || acting) return; - setActing(true); - setActionError(null); - try { - await mergeMoments(momentId, mergeTargetId); - setShowMerge(false); - navigate("/admin/review"); - } catch (err) { - setActionError(err instanceof Error ? err.message : "Merge failed"); - } finally { - setActing(false); - } - } - - // ── Render ── - - if (loading) return
Loading…
; - if (error) - return ( -
- - ← Back to queue - -
Error: {error}
-
- ); - if (!moment) return null; - - return ( -
- - ← Back to queue - - - {/* ── Moment header ── */} -
-

{moment.title}

- -
- - {/* ── Moment data ── */} -
-
- - {moment.content_type} -
-
- - - {formatTime(moment.start_time)} – {formatTime(moment.end_time)} - -
-
- - - {moment.creator_name} · {moment.video_filename} - -
- {moment.plugins && moment.plugins.length > 0 && ( -
- - {moment.plugins.join(", ")} -
- )} -
- -

{moment.summary}

-
- {moment.raw_transcript && ( -
- -

{moment.raw_transcript}

-
- )} -
- - {/* ── Action error ── */} - {actionError &&
{actionError}
} - - {/* ── Edit mode ── */} - {editing ? ( -
-

Edit Moment

-
- - setEditTitle(e.target.value)} - /> -
-
- -