feat: remove review workflow — unused gate that blocked nothing

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'.
This commit is contained in:
jlightner 2026-03-31 02:34:12 +00:00
parent 1ac3db77a1
commit 52e7e3bbc2
19 changed files with 38 additions and 1989 deletions

View file

@ -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))

View file

@ -59,9 +59,6 @@ class Settings(BaseSettings):
# Prompt templates # Prompt templates
prompts_path: str = "./prompts" 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 — when True, pipeline captures full LLM prompts and responses
debug_mode: bool = False debug_mode: bool = False

View file

@ -12,7 +12,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from config import get_settings 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: def _setup_logging() -> None:
@ -81,7 +81,6 @@ app.include_router(health.router)
app.include_router(creators.router, prefix="/api/v1") app.include_router(creators.router, prefix="/api/v1")
app.include_router(ingest.router, prefix="/api/v1") app.include_router(ingest.router, prefix="/api/v1")
app.include_router(pipeline.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(reports.router, prefix="/api/v1")
app.include_router(search.router, prefix="/api/v1") app.include_router(search.router, prefix="/api/v1")
app.include_router(techniques.router, prefix="/api/v1") app.include_router(techniques.router, prefix="/api/v1")

View file

@ -43,7 +43,6 @@ class ProcessingStatus(str, enum.Enum):
pending = "pending" pending = "pending"
transcribed = "transcribed" transcribed = "transcribed"
extracted = "extracted" extracted = "extracted"
reviewed = "reviewed"
published = "published" published = "published"
@ -55,14 +54,6 @@ class KeyMomentContentType(str, enum.Enum):
workflow = "workflow" 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): class SourceQuality(str, enum.Enum):
"""Derived source quality for technique pages.""" """Derived source quality for technique pages."""
structured = "structured" structured = "structured"
@ -70,13 +61,6 @@ class SourceQuality(str, enum.Enum):
unstructured = "unstructured" unstructured = "unstructured"
class PageReviewStatus(str, enum.Enum):
"""Review lifecycle for technique pages."""
draft = "draft"
reviewed = "reviewed"
published = "published"
class RelationshipType(str, enum.Enum): class RelationshipType(str, enum.Enum):
"""Types of links between technique pages.""" """Types of links between technique pages."""
same_technique_other_creator = "same_technique_other_creator" same_technique_other_creator = "same_technique_other_creator"
@ -197,11 +181,6 @@ class KeyMoment(Base):
nullable=False, nullable=False,
) )
plugins: Mapped[list[str] | None] = mapped_column(ARRAY(String), nullable=True) 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) raw_transcript: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now() default=_now, server_default=func.now()
@ -237,11 +216,6 @@ class TechniquePage(Base):
nullable=True, nullable=True,
) )
view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0") 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( created_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now() default=_now, server_default=func.now()
) )

View file

@ -687,7 +687,7 @@ def stage5_synthesis(self, video_id: str) -> str:
each group into a TechniquePage, creates/updates page rows, and links each group into a TechniquePage, creates/updates page rows, and links
KeyMoments to their TechniquePage. 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. Returns the video_id for chain compatibility.
""" """
@ -867,9 +867,6 @@ def stage5_synthesis(self, video_id: str) -> str:
m.technique_page_id = page.id m.technique_page_id = page.id
# Update processing_status # 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() session.commit()
@ -1085,7 +1082,7 @@ def run_pipeline(video_id: str) -> str:
stages that still need to run. For example: stages that still need to run. For example:
- pending/transcribed stages 2, 3, 4, 5 - pending/transcribed stages 2, 3, 4, 5
- extracted stages 4, 5 - extracted stages 4, 5
- reviewed/published no-op - published no-op
Returns the video_id. Returns the video_id.
""" """
@ -1127,7 +1124,7 @@ def run_pipeline(video_id: str) -> str:
stage5_synthesis.s(), stage5_synthesis.s(),
stage6_embed_and_index.s(), stage6_embed_and_index.s(),
] ]
elif status in (ProcessingStatus.reviewed, ProcessingStatus.published): elif status == ProcessingStatus.published:
logger.info( logger.info(
"run_pipeline: video_id=%s already at status=%s, nothing to do.", "run_pipeline: video_id=%s already at status=%s, nothing to do.",
video_id, status.value, video_id, status.value,

View file

@ -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)

View file

@ -109,7 +109,6 @@ class KeyMomentRead(KeyMomentBase):
id: uuid.UUID id: uuid.UUID
source_video_id: uuid.UUID source_video_id: uuid.UUID
technique_page_id: uuid.UUID | None = None technique_page_id: uuid.UUID | None = None
review_status: str = "pending"
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@ -139,7 +138,6 @@ class TechniquePageRead(TechniquePageBase):
creator_slug: str = "" creator_slug: str = ""
source_quality: str | None = None source_quality: str | None = None
view_count: int = 0 view_count: int = 0
review_status: str = "draft"
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@ -200,60 +198,6 @@ class PaginatedResponse(BaseModel):
limit: int = 50 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 ─────────────────────────────────────────────────────────────────── # ── Search ───────────────────────────────────────────────────────────────────
class SearchResultItem(BaseModel): class SearchResultItem(BaseModel):

View file

@ -312,7 +312,6 @@ def test_stage4_classification_assigns_tags(
s.llm_fallback_url = "http://mock:11434/v1" s.llm_fallback_url = "http://mock:11434/v1"
s.llm_fallback_model = "test-model" s.llm_fallback_model = "test-model"
s.database_url = TEST_DATABASE_URL_SYNC.replace("psycopg2", "asyncpg") s.database_url = TEST_DATABASE_URL_SYNC.replace("psycopg2", "asyncpg")
s.review_mode = True
mock_settings.return_value = s mock_settings.return_value = s
mock_tags.return_value = { 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_url = "http://mock:11434/v1"
s.llm_fallback_model = "test-model" s.llm_fallback_model = "test-model"
s.database_url = TEST_DATABASE_URL_SYNC.replace("psycopg2", "asyncpg") s.database_url = TEST_DATABASE_URL_SYNC.replace("psycopg2", "asyncpg")
s.review_mode = True
mock_settings.return_value = s mock_settings.return_value = s
mock_tags.return_value = { mock_tags.return_value = {
@ -461,7 +459,7 @@ def test_stage5_synthesis_creates_technique_pages(
video = session.execute( video = session.execute(
select(SourceVideo).where(SourceVideo.id == video_id) select(SourceVideo).where(SourceVideo.id == video_id)
).scalar_one() ).scalar_one()
assert video.processing_status == ProcessingStatus.reviewed assert video.processing_status == ProcessingStatus.published
finally: finally:
session.close() 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_url = "http://mock:11434/v1"
s.llm_fallback_model = "test-model" s.llm_fallback_model = "test-model"
s.database_url = TEST_DATABASE_URL_SYNC.replace("psycopg2", "asyncpg") s.database_url = TEST_DATABASE_URL_SYNC.replace("psycopg2", "asyncpg")
s.review_mode = True
s.embedding_api_url = "http://mock:11434/v1" s.embedding_api_url = "http://mock:11434/v1"
s.embedding_model = "test-embed" s.embedding_model = "test-embed"
s.embedding_dimensions = 768 s.embedding_dimensions = 768

View file

@ -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

View file

@ -50,9 +50,7 @@
--color-btn-reject: #dc2626; --color-btn-reject: #dc2626;
--color-btn-reject-hover: #b91c1c; --color-btn-reject-hover: #b91c1c;
/* Mode toggle (green/amber work on dark) */ /* Toggle colors */
--color-toggle-review: #10b981;
--color-toggle-auto: #f59e0b;
--color-toggle-track: #6b7280; --color-toggle-track: #6b7280;
--color-toggle-track-active: #059669; --color-toggle-track-active: #059669;
--color-toggle-thumb: #fff; --color-toggle-thumb: #fff;
@ -478,73 +476,6 @@ a.app-footer__repo:hover {
/* ── Mode toggle ──────────────────────────────────────────────────────────── */ /* ── 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 Mode Toggle ────────────────────────────────────────────────────── */
.debug-toggle { .debug-toggle {
@ -572,7 +503,7 @@ a.app-footer__repo:hover {
} }
.debug-toggle__switch--active { .debug-toggle__switch--active {
background: var(--color-toggle-review); background: #10b981;
} }
.debug-toggle__switch::after { .debug-toggle__switch::after {
@ -2818,20 +2749,6 @@ a.app-footer__repo:hover {
white-space: nowrap; 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 { .pipeline-video__actions {
display: flex; display: flex;
gap: 0.375rem; gap: 0.375rem;

View file

@ -5,8 +5,6 @@ import TechniquePage from "./pages/TechniquePage";
import CreatorsBrowse from "./pages/CreatorsBrowse"; import CreatorsBrowse from "./pages/CreatorsBrowse";
import CreatorDetail from "./pages/CreatorDetail"; import CreatorDetail from "./pages/CreatorDetail";
import TopicsBrowse from "./pages/TopicsBrowse"; import TopicsBrowse from "./pages/TopicsBrowse";
import ReviewQueue from "./pages/ReviewQueue";
import MomentDetail from "./pages/MomentDetail";
import AdminReports from "./pages/AdminReports"; import AdminReports from "./pages/AdminReports";
import AdminPipeline from "./pages/AdminPipeline"; import AdminPipeline from "./pages/AdminPipeline";
import AdminDropdown from "./components/AdminDropdown"; import AdminDropdown from "./components/AdminDropdown";
@ -42,8 +40,6 @@ export default function App() {
<Route path="/topics" element={<TopicsBrowse />} /> <Route path="/topics" element={<TopicsBrowse />} />
{/* Admin routes */} {/* Admin routes */}
<Route path="/admin/review" element={<ReviewQueue />} />
<Route path="/admin/review/:momentId" element={<MomentDetail />} />
<Route path="/admin/reports" element={<AdminReports />} /> <Route path="/admin/reports" element={<AdminReports />} />
<Route path="/admin/pipeline" element={<AdminPipeline />} /> <Route path="/admin/pipeline" element={<AdminPipeline />} />

View file

@ -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<T>(url: string, init?: RequestInit): Promise<T> {
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<T>;
}
// ── Queue ────────────────────────────────────────────────────────────────────
export async function fetchQueue(
params: QueueParams = {},
): Promise<ReviewQueueResponse> {
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<ReviewQueueResponse>(
`${BASE}/queue${query ? `?${query}` : ""}`,
);
}
export async function fetchMoment(
momentId: string,
): Promise<ReviewQueueItem> {
return request<ReviewQueueItem>(`${BASE}/moments/${momentId}`);
}
export async function fetchStats(): Promise<ReviewStatsResponse> {
return request<ReviewStatsResponse>(`${BASE}/stats`);
}
// ── Actions ──────────────────────────────────────────────────────────────────
export async function approveMoment(id: string): Promise<KeyMomentRead> {
return request<KeyMomentRead>(`${BASE}/moments/${id}/approve`, {
method: "POST",
});
}
export async function rejectMoment(id: string): Promise<KeyMomentRead> {
return request<KeyMomentRead>(`${BASE}/moments/${id}/reject`, {
method: "POST",
});
}
export async function editMoment(
id: string,
data: MomentEditRequest,
): Promise<KeyMomentRead> {
return request<KeyMomentRead>(`${BASE}/moments/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
export async function splitMoment(
id: string,
splitTime: number,
): Promise<KeyMomentRead[]> {
const body: MomentSplitRequest = { split_time: splitTime };
return request<KeyMomentRead[]>(`${BASE}/moments/${id}/split`, {
method: "POST",
body: JSON.stringify(body),
});
}
export async function mergeMoments(
id: string,
targetId: string,
): Promise<KeyMomentRead> {
const body: MomentMergeRequest = { target_moment_id: targetId };
return request<KeyMomentRead>(`${BASE}/moments/${id}/merge`, {
method: "POST",
body: JSON.stringify(body),
});
}
// ── Mode ─────────────────────────────────────────────────────────────────────
export async function getReviewMode(): Promise<ReviewModeResponse> {
return request<ReviewModeResponse>(`${BASE}/mode`);
}
export async function setReviewMode(
enabled: boolean,
): Promise<ReviewModeResponse> {
return request<ReviewModeResponse>(`${BASE}/mode`, {
method: "PUT",
body: JSON.stringify({ review_mode: enabled }),
});
}

View file

@ -63,7 +63,6 @@ export interface TechniquePageDetail {
creator_id: string; creator_id: string;
source_quality: string | null; source_quality: string | null;
view_count: number; view_count: number;
review_status: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
key_moments: KeyMomentSummary[]; key_moments: KeyMomentSummary[];
@ -102,7 +101,6 @@ export interface TechniqueListItem {
creator_slug: string; creator_slug: string;
source_quality: string | null; source_quality: string | null;
view_count: number; view_count: number;
review_status: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }

View file

@ -42,14 +42,6 @@ export default function AdminDropdown() {
</button> </button>
{open && ( {open && (
<div className="admin-dropdown__menu" role="menu"> <div className="admin-dropdown__menu" role="menu">
<Link
to="/admin/review"
className="admin-dropdown__item"
role="menuitem"
onClick={() => setOpen(false)}
>
Review
</Link>
<Link <Link
to="/admin/reports" to="/admin/reports"
className="admin-dropdown__item" className="admin-dropdown__item"

View file

@ -1,59 +0,0 @@
/**
* Review / Auto mode toggle switch.
*
* Reads and writes mode via getReviewMode / setReviewMode API.
* Green dot = review mode active; amber = auto mode.
*/
import { useEffect, useState } from "react";
import { getReviewMode, setReviewMode } from "../api/client";
export default function ModeToggle() {
const [reviewMode, setReviewModeState] = useState<boolean | null>(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 (
<div className="mode-toggle">
<span
className={`mode-toggle__dot ${reviewMode ? "mode-toggle__dot--review" : "mode-toggle__dot--auto"}`}
/>
<span className="mode-toggle__label">
{reviewMode ? "Review Mode" : "Auto Mode"}
</span>
<button
type="button"
className={`mode-toggle__switch ${reviewMode ? "mode-toggle__switch--active" : ""}`}
onClick={handleToggle}
disabled={toggling}
aria-label={`Switch to ${reviewMode ? "auto" : "review"} mode`}
/>
</div>
);
}

View file

@ -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 (
<span className={`badge badge--${normalized}`}>
{normalized}
</span>
);
}

View file

@ -613,14 +613,6 @@ export default function AdminPipeline() {
<span className="pipeline-video__time"> <span className="pipeline-video__time">
{formatDate(video.last_event_at)} {formatDate(video.last_event_at)}
</span> </span>
<a
className="pipeline-video__review-link"
href="/admin/review"
title={`Review moments from ${video.creator_name}`}
onClick={(e) => e.stopPropagation()}
>
Moments
</a>
</div> </div>
<div className="pipeline-video__actions" onClick={(e) => e.stopPropagation()}> <div className="pipeline-video__actions" onClick={(e) => e.stopPropagation()}>

View file

@ -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<ReviewQueueItem | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(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<ReviewQueueItem[]>([]);
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 <div className="loading">Loading</div>;
if (error)
return (
<div>
<Link to="/admin/review" className="back-link">
Back to queue
</Link>
<div className="loading error-text">Error: {error}</div>
</div>
);
if (!moment) return null;
return (
<div className="detail-page">
<Link to="/admin/review" className="back-link">
Back to queue
</Link>
{/* ── Moment header ── */}
<div className="detail-header">
<h2>{moment.title}</h2>
<StatusBadge status={moment.review_status} />
</div>
{/* ── Moment data ── */}
<div className="card detail-card">
<div className="detail-field">
<label>Content Type</label>
<span>{moment.content_type}</span>
</div>
<div className="detail-field">
<label>Time Range</label>
<span>
{formatTime(moment.start_time)} {formatTime(moment.end_time)}
</span>
</div>
<div className="detail-field">
<label>Source</label>
<span>
{moment.creator_name} · {moment.video_filename}
</span>
</div>
{moment.plugins && moment.plugins.length > 0 && (
<div className="detail-field">
<label>Plugins</label>
<span>{moment.plugins.join(", ")}</span>
</div>
)}
<div className="detail-field detail-field--full">
<label>Summary</label>
<p>{moment.summary}</p>
</div>
{moment.raw_transcript && (
<div className="detail-field detail-field--full">
<label>Raw Transcript</label>
<p className="detail-transcript">{moment.raw_transcript}</p>
</div>
)}
</div>
{/* ── Action error ── */}
{actionError && <div className="action-error">{actionError}</div>}
{/* ── Edit mode ── */}
{editing ? (
<div className="card edit-form">
<h3>Edit Moment</h3>
<div className="edit-field">
<label htmlFor="edit-title">Title</label>
<input
id="edit-title"
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
/>
</div>
<div className="edit-field">
<label htmlFor="edit-summary">Summary</label>
<textarea
id="edit-summary"
rows={4}
value={editSummary}
onChange={(e) => setEditSummary(e.target.value)}
/>
</div>
<div className="edit-field">
<label htmlFor="edit-content-type">Content Type</label>
<input
id="edit-content-type"
type="text"
value={editContentType}
onChange={(e) => setEditContentType(e.target.value)}
/>
</div>
<div className="edit-actions">
<button
type="button"
className="btn btn--approve"
onClick={handleEditSave}
disabled={acting}
>
Save
</button>
<button
type="button"
className="btn"
onClick={() => setEditing(false)}
disabled={acting}
>
Cancel
</button>
</div>
</div>
) : (
/* ── Action buttons ── */
<div className="action-bar">
<button
type="button"
className="btn btn--approve"
onClick={handleApprove}
disabled={acting}
>
Approve
</button>
<button
type="button"
className="btn btn--reject"
onClick={handleReject}
disabled={acting}
>
Reject
</button>
<button
type="button"
className="btn"
onClick={startEdit}
disabled={acting}
>
Edit
</button>
<button
type="button"
className="btn"
onClick={openSplitDialog}
disabled={acting}
>
Split
</button>
<button
type="button"
className="btn"
onClick={openMergeDialog}
disabled={acting}
>
Merge
</button>
</div>
)}
{/* ── Split dialog ── */}
{showSplit && (
<div className="dialog-overlay" onClick={() => setShowSplit(false)}>
<div className="dialog" onClick={(e) => e.stopPropagation()}>
<h3>Split Moment</h3>
<p className="dialog__hint">
Enter a timestamp (in seconds) between{" "}
{formatTime(moment.start_time)} and {formatTime(moment.end_time)}.
</p>
<div className="edit-field">
<label htmlFor="split-time">Split Time (seconds)</label>
<input
id="split-time"
type="number"
step="0.1"
min={moment.start_time}
max={moment.end_time}
value={splitTime}
onChange={(e) => setSplitTime(e.target.value)}
placeholder={`e.g. ${((moment.start_time + moment.end_time) / 2).toFixed(1)}`}
/>
</div>
<div className="dialog__actions">
<button
type="button"
className="btn btn--approve"
onClick={handleSplit}
disabled={acting}
>
Split
</button>
<button
type="button"
className="btn"
onClick={() => setShowSplit(false)}
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* ── Merge dialog ── */}
{showMerge && (
<div className="dialog-overlay" onClick={() => setShowMerge(false)}>
<div className="dialog" onClick={(e) => e.stopPropagation()}>
<h3>Merge Moment</h3>
<p className="dialog__hint">
Select another moment from the same video to merge with.
</p>
{mergeCandidates.length === 0 ? (
<p className="dialog__hint">
No other moments from this video available.
</p>
) : (
<div className="edit-field">
<label htmlFor="merge-target">Target Moment</label>
<select
id="merge-target"
value={mergeTargetId}
onChange={(e) => setMergeTargetId(e.target.value)}
>
<option value="">Select a moment</option>
{mergeCandidates.map((c) => (
<option key={c.id} value={c.id}>
{c.title} ({formatTime(c.start_time)} {" "}
{formatTime(c.end_time)})
</option>
))}
</select>
</div>
)}
<div className="dialog__actions">
<button
type="button"
className="btn btn--approve"
onClick={handleMerge}
disabled={acting || !mergeTargetId}
>
Merge
</button>
<button
type="button"
className="btn"
onClick={() => setShowMerge(false)}
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -1,189 +0,0 @@
/**
* Admin review queue page.
*
* Shows stats bar, status filter tabs, paginated moment list, and mode toggle.
*/
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import {
fetchQueue,
fetchStats,
type ReviewQueueItem,
type ReviewStatsResponse,
} from "../api/client";
import StatusBadge from "../components/StatusBadge";
import ModeToggle from "../components/ModeToggle";
const PAGE_SIZE = 20;
type StatusFilter = "all" | "pending" | "approved" | "edited" | "rejected";
const FILTERS: { label: string; value: StatusFilter }[] = [
{ label: "All", value: "all" },
{ label: "Pending", value: "pending" },
{ label: "Approved", value: "approved" },
{ label: "Edited", value: "edited" },
{ label: "Rejected", value: "rejected" },
];
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 ReviewQueue() {
const [items, setItems] = useState<ReviewQueueItem[]>([]);
const [stats, setStats] = useState<ReviewStatsResponse | null>(null);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [filter, setFilter] = useState<StatusFilter>("pending");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async (status: StatusFilter, page: number) => {
setLoading(true);
setError(null);
try {
const [queueRes, statsRes] = await Promise.all([
fetchQueue({
status: status === "all" ? undefined : status,
offset: page,
limit: PAGE_SIZE,
}),
fetchStats(),
]);
setItems(queueRes.items);
setTotal(queueRes.total);
setStats(statsRes);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load queue");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadData(filter, offset);
}, [filter, offset, loadData]);
function handleFilterChange(f: StatusFilter) {
setFilter(f);
setOffset(0);
}
const hasNext = offset + PAGE_SIZE < total;
const hasPrev = offset > 0;
return (
<div>
{/* ── Header row with title and mode toggle ── */}
<div className="queue-header">
<h2>Review Queue</h2>
<ModeToggle />
</div>
{/* ── Stats bar ── */}
{stats && (
<div className="stats-bar">
<div className="stats-card stats-card--pending">
<span className="stats-card__count">{stats.pending}</span>
<span className="stats-card__label">Pending</span>
</div>
<div className="stats-card stats-card--approved">
<span className="stats-card__count">{stats.approved}</span>
<span className="stats-card__label">Approved</span>
</div>
<div className="stats-card stats-card--edited">
<span className="stats-card__count">{stats.edited}</span>
<span className="stats-card__label">Edited</span>
</div>
<div className="stats-card stats-card--rejected">
<span className="stats-card__count">{stats.rejected}</span>
<span className="stats-card__label">Rejected</span>
</div>
</div>
)}
{/* ── Filter tabs ── */}
<div className="filter-tabs">
{FILTERS.map((f) => (
<button
key={f.value}
type="button"
className={`filter-tab ${filter === f.value ? "filter-tab--active" : ""}`}
onClick={() => handleFilterChange(f.value)}
>
{f.label}
</button>
))}
</div>
{/* ── Queue list ── */}
{loading ? (
<div className="loading">Loading</div>
) : error ? (
<div className="loading error-text">Error: {error}</div>
) : items.length === 0 ? (
<div className="empty-state">
<p>No moments match the "{filter}" filter.</p>
</div>
) : (
<>
<div className="queue-list">
{items.map((item) => (
<Link
key={item.id}
to={`/admin/review/${item.id}`}
className="queue-card"
>
<div className="queue-card__header">
<span className="queue-card__title">{item.title}</span>
<StatusBadge status={item.review_status} />
</div>
<p className="queue-card__summary">
{item.summary.length > 150
? `${item.summary.slice(0, 150)}`
: item.summary}
</p>
<div className="queue-card__meta">
<span>{item.creator_name}</span>
<span className="queue-card__separator">·</span>
<span>{item.video_filename}</span>
<span className="queue-card__separator">·</span>
<span>
{formatTime(item.start_time)} {formatTime(item.end_time)}
</span>
</div>
</Link>
))}
</div>
{/* ── Pagination ── */}
<div className="pagination">
<button
type="button"
className="btn"
disabled={!hasPrev}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
>
Previous
</button>
<span className="pagination__info">
{offset + 1}{Math.min(offset + PAGE_SIZE, total)} of {total}
</span>
<button
type="button"
className="btn"
disabled={!hasNext}
onClick={() => setOffset(offset + PAGE_SIZE)}
>
Next
</button>
</div>
</>
)}
</div>
);
}