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:
jlightner 2026-03-30 02:53:56 -05:00
parent e08e8d021f
commit 324e933670
11 changed files with 1120 additions and 2 deletions

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

View file

@ -12,7 +12,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from config import get_settings from config import get_settings
from routers import creators, health, ingest, pipeline, 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")

View file

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

View file

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

View file

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

View file

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

View file

@ -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),
});
}

View 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>
);
}

View 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>
);
}

View file

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