feat: Content issue reporting — submit from technique pages, manage in admin reports page
- ContentReport model with generic content_type/content_id (supports any entity) - Alembic migration 003: content_reports table with status + content indexes - POST /reports (public), GET/PATCH /admin/reports (admin triage) - Report modal on technique pages with issue type dropdown + description - Admin reports page with status filter, expand/collapse detail, triage actions - All CSS uses var(--*) tokens, dark theme consistent
This commit is contained in:
parent
e08e8d021f
commit
324e933670
11 changed files with 1120 additions and 2 deletions
47
alembic/versions/003_content_reports.py
Normal file
47
alembic/versions/003_content_reports.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Create content_reports table.
|
||||
|
||||
Revision ID: 003_content_reports
|
||||
Revises: 002_technique_page_versions
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "003_content_reports"
|
||||
down_revision = "002_technique_page_versions"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"content_reports",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()),
|
||||
sa.Column("content_type", sa.String(50), nullable=False),
|
||||
sa.Column("content_id", UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("content_title", sa.String(500), nullable=True),
|
||||
sa.Column("report_type", sa.Enum(
|
||||
"inaccurate", "missing_info", "wrong_attribution", "formatting", "other",
|
||||
name="report_type", create_constraint=True,
|
||||
), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.Enum(
|
||||
"open", "acknowledged", "resolved", "dismissed",
|
||||
name="report_status", create_constraint=True,
|
||||
), nullable=False, server_default="open"),
|
||||
sa.Column("admin_notes", sa.Text(), nullable=True),
|
||||
sa.Column("page_url", sa.String(1000), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("resolved_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
|
||||
op.create_index("ix_content_reports_status_created", "content_reports", ["status", "created_at"])
|
||||
op.create_index("ix_content_reports_content", "content_reports", ["content_type", "content_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_content_reports_content")
|
||||
op.drop_index("ix_content_reports_status_created")
|
||||
op.drop_table("content_reports")
|
||||
sa.Enum(name="report_status").drop(op.get_bind(), checkfirst=True)
|
||||
sa.Enum(name="report_type").drop(op.get_bind(), checkfirst=True)
|
||||
|
|
@ -12,7 +12,7 @@ from fastapi import FastAPI
|
|||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from config import get_settings
|
||||
from routers import creators, health, ingest, pipeline, review, search, techniques, topics, videos
|
||||
from routers import creators, health, ingest, pipeline, reports, review, search, techniques, topics, videos
|
||||
|
||||
|
||||
def _setup_logging() -> None:
|
||||
|
|
@ -82,6 +82,7 @@ app.include_router(creators.router, prefix="/api/v1")
|
|||
app.include_router(ingest.router, prefix="/api/v1")
|
||||
app.include_router(pipeline.router, prefix="/api/v1")
|
||||
app.include_router(review.router, prefix="/api/v1")
|
||||
app.include_router(reports.router, prefix="/api/v1")
|
||||
app.include_router(search.router, prefix="/api/v1")
|
||||
app.include_router(techniques.router, prefix="/api/v1")
|
||||
app.include_router(topics.router, prefix="/api/v1")
|
||||
|
|
|
|||
|
|
@ -319,3 +319,62 @@ class Tag(Base):
|
|||
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
category: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
aliases: Mapped[list[str] | None] = mapped_column(ARRAY(String), nullable=True)
|
||||
|
||||
|
||||
# ── Content Report Enums ─────────────────────────────────────────────────────
|
||||
|
||||
class ReportType(str, enum.Enum):
|
||||
"""Classification of user-submitted content reports."""
|
||||
inaccurate = "inaccurate"
|
||||
missing_info = "missing_info"
|
||||
wrong_attribution = "wrong_attribution"
|
||||
formatting = "formatting"
|
||||
other = "other"
|
||||
|
||||
|
||||
class ReportStatus(str, enum.Enum):
|
||||
"""Triage status for content reports."""
|
||||
open = "open"
|
||||
acknowledged = "acknowledged"
|
||||
resolved = "resolved"
|
||||
dismissed = "dismissed"
|
||||
|
||||
|
||||
# ── Content Report ───────────────────────────────────────────────────────────
|
||||
|
||||
class ContentReport(Base):
|
||||
"""User-submitted report about a content issue.
|
||||
|
||||
Generic: content_type + content_id can reference any entity
|
||||
(technique_page, key_moment, creator, or general).
|
||||
"""
|
||||
__tablename__ = "content_reports"
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
content_type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, doc="Entity type: technique_page, key_moment, creator, general"
|
||||
)
|
||||
content_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), nullable=True, doc="FK to the reported entity (null for general reports)"
|
||||
)
|
||||
content_title: Mapped[str | None] = mapped_column(
|
||||
String(500), nullable=True, doc="Snapshot of entity title at report time"
|
||||
)
|
||||
report_type: Mapped[ReportType] = mapped_column(
|
||||
Enum(ReportType, name="report_type", create_constraint=True),
|
||||
nullable=False,
|
||||
)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[ReportStatus] = mapped_column(
|
||||
Enum(ReportStatus, name="report_status", create_constraint=True),
|
||||
default=ReportStatus.open,
|
||||
server_default="open",
|
||||
)
|
||||
admin_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
page_url: Mapped[str | None] = mapped_column(
|
||||
String(1000), nullable=True, doc="URL the user was on when reporting"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=_now, server_default=func.now()
|
||||
)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
|
|
|
|||
147
backend/routers/reports.py
Normal file
147
backend/routers/reports.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"""Content reports router — public submission + admin management.
|
||||
|
||||
Public:
|
||||
POST /reports Submit a content issue report
|
||||
|
||||
Admin:
|
||||
GET /admin/reports List reports (filterable by status, content_type)
|
||||
GET /admin/reports/{id} Get single report detail
|
||||
PATCH /admin/reports/{id} Update status / add admin notes
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_session
|
||||
from models import ContentReport, ReportStatus
|
||||
from schemas import (
|
||||
ContentReportCreate,
|
||||
ContentReportListResponse,
|
||||
ContentReportRead,
|
||||
ContentReportUpdate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("chrysopedia.reports")
|
||||
|
||||
router = APIRouter(tags=["reports"])
|
||||
|
||||
|
||||
# ── Public ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/reports", response_model=ContentReportRead, status_code=201)
|
||||
async def submit_report(
|
||||
body: ContentReportCreate,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Submit a content issue report (public, no auth)."""
|
||||
report = ContentReport(
|
||||
content_type=body.content_type,
|
||||
content_id=body.content_id,
|
||||
content_title=body.content_title,
|
||||
report_type=body.report_type,
|
||||
description=body.description,
|
||||
page_url=body.page_url,
|
||||
)
|
||||
db.add(report)
|
||||
await db.commit()
|
||||
await db.refresh(report)
|
||||
|
||||
logger.info(
|
||||
"New content report: id=%s type=%s content=%s/%s",
|
||||
report.id, report.report_type, report.content_type, report.content_id,
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
# ── Admin ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/admin/reports", response_model=ContentReportListResponse)
|
||||
async def list_reports(
|
||||
status: Annotated[str | None, Query(description="Filter by status")] = None,
|
||||
content_type: Annotated[str | None, Query(description="Filter by content type")] = None,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List content reports with optional filters."""
|
||||
stmt = select(ContentReport)
|
||||
|
||||
if status:
|
||||
stmt = stmt.where(ContentReport.status == status)
|
||||
if content_type:
|
||||
stmt = stmt.where(ContentReport.content_type == content_type)
|
||||
|
||||
# Count
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total = (await db.execute(count_stmt)).scalar() or 0
|
||||
|
||||
# Fetch page
|
||||
stmt = stmt.order_by(ContentReport.created_at.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
items = result.scalars().all()
|
||||
|
||||
return {"items": items, "total": total, "offset": offset, "limit": limit}
|
||||
|
||||
|
||||
@router.get("/admin/reports/{report_id}", response_model=ContentReportRead)
|
||||
async def get_report(
|
||||
report_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get a single content report by ID."""
|
||||
result = await db.execute(
|
||||
select(ContentReport).where(ContentReport.id == report_id)
|
||||
)
|
||||
report = result.scalar_one_or_none()
|
||||
if not report:
|
||||
raise HTTPException(status_code=404, detail="Report not found")
|
||||
return report
|
||||
|
||||
|
||||
@router.patch("/admin/reports/{report_id}", response_model=ContentReportRead)
|
||||
async def update_report(
|
||||
report_id: uuid.UUID,
|
||||
body: ContentReportUpdate,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update report status and/or admin notes."""
|
||||
result = await db.execute(
|
||||
select(ContentReport).where(ContentReport.id == report_id)
|
||||
)
|
||||
report = result.scalar_one_or_none()
|
||||
if not report:
|
||||
raise HTTPException(status_code=404, detail="Report not found")
|
||||
|
||||
if body.status is not None:
|
||||
# Validate status value
|
||||
try:
|
||||
ReportStatus(body.status)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Invalid status: {body.status}. Must be one of: open, acknowledged, resolved, dismissed",
|
||||
)
|
||||
report.status = body.status
|
||||
if body.status in ("resolved", "dismissed"):
|
||||
report.resolved_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
elif body.status == "open":
|
||||
report.resolved_at = None
|
||||
|
||||
if body.admin_notes is not None:
|
||||
report.admin_notes = body.admin_notes
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(report)
|
||||
|
||||
logger.info(
|
||||
"Report updated: id=%s status=%s",
|
||||
report.id, report.status,
|
||||
)
|
||||
return report
|
||||
|
|
@ -364,3 +364,63 @@ class CreatorBrowseItem(CreatorRead):
|
|||
"""Creator with technique and video counts for browse pages."""
|
||||
technique_count: int = 0
|
||||
video_count: int = 0
|
||||
|
||||
|
||||
# ── Content Reports ──────────────────────────────────────────────────────────
|
||||
|
||||
class ContentReportCreate(BaseModel):
|
||||
"""Public submission: report a content issue."""
|
||||
content_type: str = Field(
|
||||
..., description="Entity type: technique_page, key_moment, creator, general"
|
||||
)
|
||||
content_id: uuid.UUID | None = Field(
|
||||
None, description="ID of the reported entity (null for general reports)"
|
||||
)
|
||||
content_title: str | None = Field(
|
||||
None, description="Title of the reported content (for display context)"
|
||||
)
|
||||
report_type: str = Field(
|
||||
..., description="inaccurate, missing_info, wrong_attribution, formatting, other"
|
||||
)
|
||||
description: str = Field(
|
||||
..., min_length=10, max_length=2000,
|
||||
description="Description of the issue"
|
||||
)
|
||||
page_url: str | None = Field(
|
||||
None, description="URL the user was on when reporting"
|
||||
)
|
||||
|
||||
|
||||
class ContentReportRead(BaseModel):
|
||||
"""Full report for admin views."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
content_type: str
|
||||
content_id: uuid.UUID | None = None
|
||||
content_title: str | None = None
|
||||
report_type: str
|
||||
description: str
|
||||
status: str = "open"
|
||||
admin_notes: str | None = None
|
||||
page_url: str | None = None
|
||||
created_at: datetime
|
||||
resolved_at: datetime | None = None
|
||||
|
||||
|
||||
class ContentReportUpdate(BaseModel):
|
||||
"""Admin update: change status and/or add notes."""
|
||||
status: str | None = Field(
|
||||
None, description="open, acknowledged, resolved, dismissed"
|
||||
)
|
||||
admin_notes: str | None = Field(
|
||||
None, max_length=2000, description="Admin notes about resolution"
|
||||
)
|
||||
|
||||
|
||||
class ContentReportListResponse(BaseModel):
|
||||
"""Paginated list of content reports."""
|
||||
items: list[ContentReportRead] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
offset: int = 0
|
||||
limit: int = 50
|
||||
|
|
|
|||
|
|
@ -1929,3 +1929,335 @@ body {
|
|||
padding-left: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Report Issue Modal ─────────────────────────────────────────────────── */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.report-modal__title {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.report-modal__context {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.report-modal__context strong {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.report-modal__success {
|
||||
color: var(--color-accent);
|
||||
margin: 0.5rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.report-modal__label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.report-modal__select {
|
||||
background: var(--color-bg-input);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.report-modal__textarea {
|
||||
background: var(--color-bg-input);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.report-modal__textarea:focus,
|
||||
.report-modal__select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.report-modal__error {
|
||||
color: var(--color-error);
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.report-modal__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.report-issue-btn {
|
||||
margin-top: 0.5rem;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.btn--small {
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg-page);
|
||||
border-color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn--primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--color-bg-input);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.btn--secondary:hover:not(:disabled) {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
background: var(--color-badge-rejected-bg);
|
||||
color: var(--color-badge-rejected-text);
|
||||
border-color: var(--color-badge-rejected-bg);
|
||||
}
|
||||
|
||||
.btn--danger:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ── Admin Reports ──────────────────────────────────────────────────────── */
|
||||
|
||||
.admin-reports {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.admin-reports__title {
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.admin-reports__subtitle {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-reports__filters {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.admin-reports__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.report-card {
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.report-card--open {
|
||||
border-left: 3px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.report-card--acknowledged {
|
||||
border-left: 3px solid var(--color-badge-pending-text);
|
||||
}
|
||||
|
||||
.report-card--resolved {
|
||||
border-left: 3px solid var(--color-badge-approved-text);
|
||||
}
|
||||
|
||||
.report-card--dismissed {
|
||||
border-left: 3px solid var(--color-text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.report-card__header {
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.report-card__header:hover {
|
||||
background: var(--color-bg-input);
|
||||
}
|
||||
|
||||
.report-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.report-card__date {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.report-card__summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.report-card__content-title {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.report-card__description {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.report-card__detail {
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.report-card__full-description {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.report-card__full-description strong {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.report-card__full-description p {
|
||||
margin: 0.25rem 0 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.report-card__url {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.report-card__url a {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.report-card__info-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.report-card__notes-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.report-card__notes {
|
||||
background: var(--color-bg-input);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.report-card__notes:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.report-card__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Status pill colors */
|
||||
.pill--open {
|
||||
background: var(--color-accent-subtle);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.pill--acknowledged {
|
||||
background: var(--color-badge-pending-bg);
|
||||
color: var(--color-badge-pending-text);
|
||||
}
|
||||
|
||||
.pill--resolved {
|
||||
background: var(--color-badge-approved-bg);
|
||||
color: var(--color-badge-approved-text);
|
||||
}
|
||||
|
||||
.pill--dismissed {
|
||||
background: var(--color-bg-input);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import CreatorDetail from "./pages/CreatorDetail";
|
|||
import TopicsBrowse from "./pages/TopicsBrowse";
|
||||
import ReviewQueue from "./pages/ReviewQueue";
|
||||
import MomentDetail from "./pages/MomentDetail";
|
||||
import AdminReports from "./pages/AdminReports";
|
||||
import ModeToggle from "./components/ModeToggle";
|
||||
|
||||
export default function App() {
|
||||
|
|
@ -21,7 +22,8 @@ export default function App() {
|
|||
<Link to="/">Home</Link>
|
||||
<Link to="/topics">Topics</Link>
|
||||
<Link to="/creators">Creators</Link>
|
||||
<Link to="/admin/review">Admin</Link>
|
||||
<Link to="/admin/review">Review</Link>
|
||||
<Link to="/admin/reports">Reports</Link>
|
||||
</nav>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
|
|
@ -42,6 +44,7 @@ export default function App() {
|
|||
{/* Admin routes */}
|
||||
<Route path="/admin/review" element={<ReviewQueue />} />
|
||||
<Route path="/admin/review/:momentId" element={<MomentDetail />} />
|
||||
<Route path="/admin/reports" element={<AdminReports />} />
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
|
|
|||
|
|
@ -272,3 +272,72 @@ export async function fetchCreator(
|
|||
): Promise<CreatorDetailResponse> {
|
||||
return request<CreatorDetailResponse>(`${BASE}/creators/${slug}`);
|
||||
}
|
||||
|
||||
|
||||
// ── Content Reports ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface ContentReportCreate {
|
||||
content_type: string;
|
||||
content_id?: string | null;
|
||||
content_title?: string | null;
|
||||
report_type: string;
|
||||
description: string;
|
||||
page_url?: string | null;
|
||||
}
|
||||
|
||||
export interface ContentReport {
|
||||
id: string;
|
||||
content_type: string;
|
||||
content_id: string | null;
|
||||
content_title: string | null;
|
||||
report_type: string;
|
||||
description: string;
|
||||
status: string;
|
||||
admin_notes: string | null;
|
||||
page_url: string | null;
|
||||
created_at: string;
|
||||
resolved_at: string | null;
|
||||
}
|
||||
|
||||
export interface ContentReportListResponse {
|
||||
items: ContentReport[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export async function submitReport(
|
||||
body: ContentReportCreate,
|
||||
): Promise<ContentReport> {
|
||||
return request<ContentReport>(`${BASE}/reports`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchReports(params: {
|
||||
status?: string;
|
||||
content_type?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
} = {}): Promise<ContentReportListResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.status) qs.set("status", params.status);
|
||||
if (params.content_type) qs.set("content_type", params.content_type);
|
||||
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<ContentReportListResponse>(
|
||||
`${BASE}/admin/reports${query ? `?${query}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateReport(
|
||||
id: string,
|
||||
body: { status?: string; admin_notes?: string },
|
||||
): Promise<ContentReport> {
|
||||
return request<ContentReport>(`${BASE}/admin/reports/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
135
frontend/src/components/ReportIssueModal.tsx
Normal file
135
frontend/src/components/ReportIssueModal.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { useState } from "react";
|
||||
import { submitReport, type ContentReportCreate } from "../api/public-client";
|
||||
|
||||
interface ReportIssueModalProps {
|
||||
contentType: string;
|
||||
contentId?: string | null;
|
||||
contentTitle?: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const REPORT_TYPES = [
|
||||
{ value: "inaccurate", label: "Inaccurate content" },
|
||||
{ value: "missing_info", label: "Missing information" },
|
||||
{ value: "wrong_attribution", label: "Wrong attribution" },
|
||||
{ value: "formatting", label: "Formatting issue" },
|
||||
{ value: "other", label: "Other" },
|
||||
];
|
||||
|
||||
export default function ReportIssueModal({
|
||||
contentType,
|
||||
contentId,
|
||||
contentTitle,
|
||||
onClose,
|
||||
}: ReportIssueModalProps) {
|
||||
const [reportType, setReportType] = useState("inaccurate");
|
||||
const [description, setDescription] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (description.trim().length < 10) {
|
||||
setError("Please provide at least 10 characters describing the issue.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const body: ContentReportCreate = {
|
||||
content_type: contentType,
|
||||
content_id: contentId ?? null,
|
||||
content_title: contentTitle ?? null,
|
||||
report_type: reportType,
|
||||
description: description.trim(),
|
||||
page_url: window.location.href,
|
||||
};
|
||||
await submitReport(body);
|
||||
setSubmitted(true);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to submit report",
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content report-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{submitted ? (
|
||||
<>
|
||||
<h3 className="report-modal__title">Thank you</h3>
|
||||
<p className="report-modal__success">
|
||||
Your report has been submitted. We'll review it shortly.
|
||||
</p>
|
||||
<button className="btn btn--primary" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="report-modal__title">Report an issue</h3>
|
||||
{contentTitle && (
|
||||
<p className="report-modal__context">
|
||||
About: <strong>{contentTitle}</strong>
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="report-modal__label">
|
||||
Issue type
|
||||
<select
|
||||
className="report-modal__select"
|
||||
value={reportType}
|
||||
onChange={(e) => setReportType(e.target.value)}
|
||||
>
|
||||
{REPORT_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="report-modal__label">
|
||||
Description
|
||||
<textarea
|
||||
className="report-modal__textarea"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe the issue…"
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="report-modal__error">{error}</p>}
|
||||
|
||||
<div className="report-modal__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn--primary"
|
||||
disabled={submitting || description.trim().length < 10}
|
||||
>
|
||||
{submitting ? "Submitting…" : "Submit report"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
246
frontend/src/pages/AdminReports.tsx
Normal file
246
frontend/src/pages/AdminReports.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* Admin content reports management page.
|
||||
*
|
||||
* Lists user-submitted issue reports with filtering by status,
|
||||
* inline triage (acknowledge/resolve/dismiss), and admin notes.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
fetchReports,
|
||||
updateReport,
|
||||
type ContentReport,
|
||||
} from "../api/public-client";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "", label: "All" },
|
||||
{ value: "open", label: "Open" },
|
||||
{ value: "acknowledged", label: "Acknowledged" },
|
||||
{ value: "resolved", label: "Resolved" },
|
||||
{ value: "dismissed", label: "Dismissed" },
|
||||
];
|
||||
|
||||
const STATUS_ACTIONS: Record<string, { label: string; next: string }[]> = {
|
||||
open: [
|
||||
{ label: "Acknowledge", next: "acknowledged" },
|
||||
{ label: "Resolve", next: "resolved" },
|
||||
{ label: "Dismiss", next: "dismissed" },
|
||||
],
|
||||
acknowledged: [
|
||||
{ label: "Resolve", next: "resolved" },
|
||||
{ label: "Dismiss", next: "dismissed" },
|
||||
{ label: "Reopen", next: "open" },
|
||||
],
|
||||
resolved: [{ label: "Reopen", next: "open" }],
|
||||
dismissed: [{ label: "Reopen", next: "open" }],
|
||||
};
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function reportTypeLabel(rt: string): string {
|
||||
return rt.replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export default function AdminReports() {
|
||||
const [reports, setReports] = useState<ContentReport[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [noteText, setNoteText] = useState("");
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchReports({
|
||||
status: statusFilter || undefined,
|
||||
limit: 100,
|
||||
});
|
||||
setReports(res.items);
|
||||
setTotal(res.total);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load reports");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [statusFilter]);
|
||||
|
||||
const handleAction = async (reportId: string, newStatus: string) => {
|
||||
setActionLoading(reportId);
|
||||
try {
|
||||
const updated = await updateReport(reportId, {
|
||||
status: newStatus,
|
||||
...(noteText.trim() ? { admin_notes: noteText.trim() } : {}),
|
||||
});
|
||||
setReports((prev) =>
|
||||
prev.map((r) => (r.id === reportId ? updated : r)),
|
||||
);
|
||||
setNoteText("");
|
||||
if (newStatus === "resolved" || newStatus === "dismissed") {
|
||||
setExpandedId(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Action failed");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
if (expandedId === id) {
|
||||
setExpandedId(null);
|
||||
setNoteText("");
|
||||
} else {
|
||||
setExpandedId(id);
|
||||
const report = reports.find((r) => r.id === id);
|
||||
setNoteText(report?.admin_notes ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-reports">
|
||||
<h2 className="admin-reports__title">Content Reports</h2>
|
||||
<p className="admin-reports__subtitle">
|
||||
{total} report{total !== 1 ? "s" : ""} total
|
||||
</p>
|
||||
|
||||
{/* Status filter */}
|
||||
<div className="admin-reports__filters">
|
||||
<div className="sort-toggle" role="group" aria-label="Filter by status">
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`sort-toggle__btn${statusFilter === opt.value ? " sort-toggle__btn--active" : ""}`}
|
||||
onClick={() => setStatusFilter(opt.value)}
|
||||
aria-pressed={statusFilter === opt.value}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="loading">Loading reports…</div>
|
||||
) : error ? (
|
||||
<div className="loading error-text">Error: {error}</div>
|
||||
) : reports.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
{statusFilter ? `No ${statusFilter} reports.` : "No reports yet."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-reports__list">
|
||||
{reports.map((report) => (
|
||||
<div
|
||||
key={report.id}
|
||||
className={`report-card report-card--${report.status}`}
|
||||
>
|
||||
<div
|
||||
className="report-card__header"
|
||||
onClick={() => toggleExpand(report.id)}
|
||||
>
|
||||
<div className="report-card__meta">
|
||||
<span className={`pill pill--${report.status}`}>
|
||||
{report.status}
|
||||
</span>
|
||||
<span className="pill">{reportTypeLabel(report.report_type)}</span>
|
||||
<span className="report-card__date">
|
||||
{formatDate(report.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="report-card__summary">
|
||||
{report.content_title && (
|
||||
<span className="report-card__content-title">
|
||||
{report.content_title}
|
||||
</span>
|
||||
)}
|
||||
<span className="report-card__description">
|
||||
{report.description.length > 120
|
||||
? report.description.slice(0, 120) + "…"
|
||||
: report.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedId === report.id && (
|
||||
<div className="report-card__detail">
|
||||
<div className="report-card__full-description">
|
||||
<strong>Full description:</strong>
|
||||
<p>{report.description}</p>
|
||||
</div>
|
||||
|
||||
{report.page_url && (
|
||||
<div className="report-card__url">
|
||||
<strong>Page:</strong>{" "}
|
||||
<a href={report.page_url} target="_blank" rel="noopener noreferrer">
|
||||
{report.page_url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="report-card__info-row">
|
||||
<span>Type: {report.content_type}</span>
|
||||
{report.content_id && <span>ID: {report.content_id.slice(0, 8)}…</span>}
|
||||
{report.resolved_at && (
|
||||
<span>Resolved: {formatDate(report.resolved_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Admin notes */}
|
||||
<label className="report-card__notes-label">
|
||||
Admin notes:
|
||||
<textarea
|
||||
className="report-card__notes"
|
||||
value={noteText}
|
||||
onChange={(e) => setNoteText(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Add notes…"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="report-card__actions">
|
||||
{(STATUS_ACTIONS[report.status] ?? []).map((action) => (
|
||||
<button
|
||||
key={action.next}
|
||||
className={`btn btn--${action.next === "resolved" ? "primary" : action.next === "dismissed" ? "danger" : "secondary"}`}
|
||||
onClick={() => handleAction(report.id, action.next)}
|
||||
disabled={actionLoading === report.id}
|
||||
>
|
||||
{actionLoading === report.id ? "…" : action.label}
|
||||
</button>
|
||||
))}
|
||||
{noteText !== (report.admin_notes ?? "") && (
|
||||
<button
|
||||
className="btn btn--secondary"
|
||||
onClick={() => handleAction(report.id, report.status)}
|
||||
disabled={actionLoading === report.id}
|
||||
>
|
||||
Save notes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
fetchTechnique,
|
||||
type TechniquePageDetail as TechniqueDetail,
|
||||
} from "../api/public-client";
|
||||
import ReportIssueModal from "../components/ReportIssueModal";
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
|
|
@ -31,6 +32,7 @@ export default function TechniquePage() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showReport, setShowReport] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
|
|
@ -164,8 +166,25 @@ export default function TechniquePage() {
|
|||
return parts.join(" · ");
|
||||
})()}
|
||||
</div>
|
||||
{/* Report issue button */}
|
||||
<button
|
||||
className="btn btn--secondary btn--small report-issue-btn"
|
||||
onClick={() => setShowReport(true)}
|
||||
>
|
||||
⚑ Report issue
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Report modal */}
|
||||
{showReport && (
|
||||
<ReportIssueModal
|
||||
contentType="technique_page"
|
||||
contentId={technique.id}
|
||||
contentTitle={technique.title}
|
||||
onClose={() => setShowReport(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{technique.summary && (
|
||||
<section className="technique-summary">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue