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 fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from config import get_settings
|
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:
|
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(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(review.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")
|
||||||
app.include_router(topics.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)
|
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
category: Mapped[str] = mapped_column(String(255), nullable=False)
|
category: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
aliases: Mapped[list[str] | None] = mapped_column(ARRAY(String), nullable=True)
|
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."""
|
"""Creator with technique and video counts for browse pages."""
|
||||||
technique_count: int = 0
|
technique_count: int = 0
|
||||||
video_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;
|
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 TopicsBrowse from "./pages/TopicsBrowse";
|
||||||
import ReviewQueue from "./pages/ReviewQueue";
|
import ReviewQueue from "./pages/ReviewQueue";
|
||||||
import MomentDetail from "./pages/MomentDetail";
|
import MomentDetail from "./pages/MomentDetail";
|
||||||
|
import AdminReports from "./pages/AdminReports";
|
||||||
import ModeToggle from "./components/ModeToggle";
|
import ModeToggle from "./components/ModeToggle";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
@ -21,7 +22,8 @@ export default function App() {
|
||||||
<Link to="/">Home</Link>
|
<Link to="/">Home</Link>
|
||||||
<Link to="/topics">Topics</Link>
|
<Link to="/topics">Topics</Link>
|
||||||
<Link to="/creators">Creators</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>
|
</nav>
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -42,6 +44,7 @@ export default function App() {
|
||||||
{/* Admin routes */}
|
{/* Admin routes */}
|
||||||
<Route path="/admin/review" element={<ReviewQueue />} />
|
<Route path="/admin/review" element={<ReviewQueue />} />
|
||||||
<Route path="/admin/review/:momentId" element={<MomentDetail />} />
|
<Route path="/admin/review/:momentId" element={<MomentDetail />} />
|
||||||
|
<Route path="/admin/reports" element={<AdminReports />} />
|
||||||
|
|
||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -272,3 +272,72 @@ export async function fetchCreator(
|
||||||
): Promise<CreatorDetailResponse> {
|
): Promise<CreatorDetailResponse> {
|
||||||
return request<CreatorDetailResponse>(`${BASE}/creators/${slug}`);
|
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,
|
fetchTechnique,
|
||||||
type TechniquePageDetail as TechniqueDetail,
|
type TechniquePageDetail as TechniqueDetail,
|
||||||
} from "../api/public-client";
|
} from "../api/public-client";
|
||||||
|
import ReportIssueModal from "../components/ReportIssueModal";
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
const m = Math.floor(seconds / 60);
|
const m = Math.floor(seconds / 60);
|
||||||
|
|
@ -31,6 +32,7 @@ export default function TechniquePage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [notFound, setNotFound] = useState(false);
|
const [notFound, setNotFound] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showReport, setShowReport] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
|
|
@ -164,8 +166,25 @@ export default function TechniquePage() {
|
||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Report issue button */}
|
||||||
|
<button
|
||||||
|
className="btn btn--secondary btn--small report-issue-btn"
|
||||||
|
onClick={() => setShowReport(true)}
|
||||||
|
>
|
||||||
|
⚑ Report issue
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Report modal */}
|
||||||
|
{showReport && (
|
||||||
|
<ReportIssueModal
|
||||||
|
contentType="technique_page"
|
||||||
|
contentId={technique.id}
|
||||||
|
contentTitle={technique.title}
|
||||||
|
onClose={() => setShowReport(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
{technique.summary && (
|
{technique.summary && (
|
||||||
<section className="technique-summary">
|
<section className="technique-summary">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue