From 324e9336707a12c616fa42b4d2d31c4d3b3e1544 Mon Sep 17 00:00:00 2001 From: jlightner Date: Mon, 30 Mar 2026 02:53:56 -0500 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20Content=20issue=20reporting=20?= =?UTF-8?q?=E2=80=94=20submit=20from=20technique=20pages,=20manage=20in=20?= =?UTF-8?q?admin=20reports=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- alembic/versions/003_content_reports.py | 47 +++ backend/main.py | 3 +- backend/models.py | 59 ++++ backend/routers/reports.py | 147 ++++++++ backend/schemas.py | 60 ++++ frontend/src/App.css | 332 +++++++++++++++++++ frontend/src/App.tsx | 5 +- frontend/src/api/public-client.ts | 69 ++++ frontend/src/components/ReportIssueModal.tsx | 135 ++++++++ frontend/src/pages/AdminReports.tsx | 246 ++++++++++++++ frontend/src/pages/TechniquePage.tsx | 19 ++ 11 files changed, 1120 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/003_content_reports.py create mode 100644 backend/routers/reports.py create mode 100644 frontend/src/components/ReportIssueModal.tsx create mode 100644 frontend/src/pages/AdminReports.tsx diff --git a/alembic/versions/003_content_reports.py b/alembic/versions/003_content_reports.py new file mode 100644 index 0000000..c24bdd8 --- /dev/null +++ b/alembic/versions/003_content_reports.py @@ -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) diff --git a/backend/main.py b/backend/main.py index 32977cd..7e93318 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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") diff --git a/backend/models.py b/backend/models.py index 4321417..3935cad 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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) diff --git a/backend/routers/reports.py b/backend/routers/reports.py new file mode 100644 index 0000000..9bdbd76 --- /dev/null +++ b/backend/routers/reports.py @@ -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 diff --git a/backend/schemas.py b/backend/schemas.py index 1e6fb9b..256c5ee 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -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 diff --git a/frontend/src/App.css b/frontend/src/App.css index f6bfec9..28925c8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8b7bcd4..1dd122c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { Home Topics Creators - Admin + Review + Reports @@ -42,6 +44,7 @@ export default function App() { {/* Admin routes */} } /> } /> + } /> {/* Fallback */} } /> diff --git a/frontend/src/api/public-client.ts b/frontend/src/api/public-client.ts index 47ecdd0..d7acce0 100644 --- a/frontend/src/api/public-client.ts +++ b/frontend/src/api/public-client.ts @@ -272,3 +272,72 @@ export async function fetchCreator( ): Promise { return request(`${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 { + return request(`${BASE}/reports`, { + method: "POST", + body: JSON.stringify(body), + }); +} + +export async function fetchReports(params: { + status?: string; + content_type?: string; + offset?: number; + limit?: number; +} = {}): Promise { + 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( + `${BASE}/admin/reports${query ? `?${query}` : ""}`, + ); +} + +export async function updateReport( + id: string, + body: { status?: string; admin_notes?: string }, +): Promise { + return request(`${BASE}/admin/reports/${id}`, { + method: "PATCH", + body: JSON.stringify(body), + }); +} diff --git a/frontend/src/components/ReportIssueModal.tsx b/frontend/src/components/ReportIssueModal.tsx new file mode 100644 index 0000000..c5be1e2 --- /dev/null +++ b/frontend/src/components/ReportIssueModal.tsx @@ -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(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 ( +
+
e.stopPropagation()}> + {submitted ? ( + <> +

Thank you

+

+ Your report has been submitted. We'll review it shortly. +

+ + + ) : ( + <> +

Report an issue

+ {contentTitle && ( +

+ About: {contentTitle} +

+ )} +
+ + +