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:
parent
1ac3db77a1
commit
52e7e3bbc2
19 changed files with 38 additions and 1989 deletions
30
alembic/versions/007_drop_review_columns.py
Normal file
30
alembic/versions/007_drop_review_columns.py
Normal 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))
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,10 +867,7 @@ 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.published
|
||||||
video.processing_status = ProcessingStatus.reviewed
|
|
||||||
else:
|
|
||||||
video.processing_status = ProcessingStatus.published
|
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
elapsed = time.monotonic() - start
|
elapsed = time.monotonic() - start
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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()}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue