Overview
Implemented a comprehensive content reporting system that allows both authenticated and anonymous users to report inappropriate content (photos, albums, profiles, and comments). The system includes an admin review queue with a detailed resolution workflow, email notifications for both admins and reporters, and support for BotID verification for anonymous submissions.
Also standardized UI text across all admin pages and forms to use sentence case instead of title case, improving consistency throughout the application.
Database: Reports Table
Schema Design
File: supabase/migrations/20260203000000_add_reports.sql
Created a comprehensive reports table that supports both authenticated and anonymous reporters:
CREATE TABLE "public"."reports" (
"id" uuid DEFAULT gen_random_uuid() NOT NULL,
"reporter_id" uuid, -- NULL for anonymous reports
"reporter_email" text, -- Required for anonymous reports
"reporter_name" text, -- Required for anonymous reports
"entity_type" text NOT NULL CHECK (entity_type IN ('photo', 'album', 'profile', 'comment')),
"entity_id" uuid NOT NULL,
"reason" text NOT NULL,
"details" text,
"status" text DEFAULT 'pending' NOT NULL CHECK (status IN ('pending', 'resolved', 'dismissed')),
"reviewed_at" timestamp with time zone,
"reviewed_by" uuid, -- Admin who reviewed the report
"admin_notes" text, -- Resolution details
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
Key design decisions:
- Support for both authenticated users (via
reporter_id) and anonymous users (viareporter_emailandreporter_name) - Status tracking:
pending,resolved,dismissed - Entity type constraint ensures only valid content types can be reported
- Foreign keys to profiles for both reporter and reviewer tracking
- Indexes on
status,entity_type/entity_id,reporter_id, andreporter_emailfor efficient queries
Row Level Security
Implemented RLS policies that:
- Allow authenticated users to view their own reports
- Allow admins to view all reports
- Allow authenticated users to create reports (with their own
reporter_id) - Allow admins to update reports for review/resolution
- Enforce that authenticated users cannot set
reporter_emailorreporter_name(those are for anonymous only)
Report Submission Flow
ReportModal Component
File: src/components/shared/ReportModal.tsx
A comprehensive modal for submitting reports that:
-
Supports both authenticated and anonymous users:
- Authenticated users: Uses their profile automatically
- Anonymous users: Requires name, email, and BotID verification
-
Shows preview of reported content:
- Uses
PhotoListItemfor photo reports - Uses
AlbumMiniCardfor album reports - Displays
created_atdate instead of filename - Shows dynamic title with short_id, owner nickname, and "Untitled" handling
- Uses
-
Prevents infinite loops:
- Uses
useReffor stable callback references (handleSubmitRef,handleCloseRef) - Memoizes footer content with
useMemoto only update whenisSubmittingchanges - Prevents
ModalContextre-render issues
- Uses
// Memoize footer to prevent infinite loops
const footerContent = useMemo(
() => (
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => handleCloseRef.current?.()} disabled={isSubmitting}>
Cancel
</Button>
<Button onClick={() => handleSubmitRef.current?.()} disabled={isSubmitting} loading={isSubmitting}>
Submit report
</Button>
</div>
),
[isSubmitting],
);
- Dynamic modal title:
- Photos:
Report photo: "Untitled" (abc12) by @nickname - Albums:
Report album: "Untitled Album" by @nickname - Profiles:
Report profile: @nickname - Comments:
Report comment
- Photos:
ReportButton Component
File: src/components/shared/ReportButton.tsx
A reusable button component that:
- Opens the
ReportModalwith appropriate entity type and ID - Uses consistent styling (
variant="link"withclassName="text-xs text-foreground/60") - Placed next to views count on album pages
- Added to Comments component for reporting individual comments
- Added to profile pages for reporting user profiles
Integration Points
Reports can be submitted from:
- Photo detail pages (via
PhotoPageContent) - Album pages (next to view count)
- Profile pages (below social links)
- Comment threads (via
Commentscomponent)
Admin Review Queue
Reports Page
File: src/app/admin/reports/page.tsx
A comprehensive admin interface for reviewing reports:
-
Tabbed interface:
- Pending: Reports awaiting review
- Resolved: Reports that were resolved
- Dismissed: Reports that were dismissed
-
Report cards with detailed information:
- Two-column layout with narrow left column for labels (
whitespace-nowrap) - Shows
created_atdate at the top - Displays preview of reported content (photo/album thumbnail with clickable link)
- Shows reporter information (avatar, name, link to profile if authenticated)
- Shows owner/creator of reported content
- Displays reason and details
- Shows resolution information (date, admin who resolved/dismissed) for resolved/dismissed reports
- Horizontal rules between major sections
- Two-column layout with narrow left column for labels (
-
Bulk actions:
- Checkbox in top-right corner for selecting reports
- "Select all" / "Deselect all" button
- Bulk resolve and dismiss actions
- Sticky action bar matching challenges page styling
-
Resolution workflow:
- Single report: Click "Resolve" or "Dismiss" button
- Bulk: Select multiple reports, then "Resolve all" or "Dismiss all"
- Opens
ResolveReportModalfor resolution details
-
Empty state:
- Uses
SadSVGicon matching challenges page - Consistent styling and messaging
- Uses
-
Data revalidation:
- Explicitly invalidates
['report-counts']query key to update tab counts - Ensures accurate counts after resolving/dismissing reports
- Explicitly invalidates
ResolveReportModal Component
File: src/components/admin/ResolveReportModal.tsx
Modal for admin resolution input:
- Radio buttons for resolution type (e.g., "Content removed", "No violation found")
- Optional message field for admin notes
- Supports single report or bulk actions
- Dynamic text based on action type (resolve vs dismiss) and count
Preview Components
Files: src/app/admin/reports/page.tsx (PhotoReportPreview, AlbumReportPreview)
- Uses
getSquareThumbnailUrlfor server-side cropped thumbnails (48x48px) - Clickable thumbnails and avatars linking to content/profile pages
- Shows owner information with avatar and link
- Displays entity details (title, short_id, created_at, photo count)
Email Notifications
Admin Notifications
File: src/app/api/reports/notify/route.ts
Sends email to admins when a new report is submitted:
- Includes reporter information (name, email if anonymous)
- Shows reported entity details
- Links to admin reports page
File: src/emails/report-notification.tsx
Email template for admin notifications with:
- Reporter information
- Reported content preview
- Direct link to review the report
Reporter Notifications
File: src/app/api/reports/resolved/notify/route.ts
Sends email to reporters when their report is resolved:
- Fetches detailed entity information (photo short_id, album photo count, owner nickname, created_at)
- Only sends for resolved reports (not dismissed)
- No unsubscribe option (user-triggered action)
File: src/emails/report-resolved.tsx
Email template with rich entity details:
- Uses
getFormattedEntityTitle()helper to match ReportModal formatting - Shows entity thumbnail, title, owner, creation date
- Displays resolution type and admin message
- Formats dates consistently
function getFormattedEntityTitle(): string {
if (entityType === 'photo') {
const title = entityTitle || 'Untitled';
const quotedTitle = title === 'Untitled' ? `"${title}"` : title;
const shortIdPart = entityShortId ? ` (${entityShortId})` : '';
const ownerPart = entityOwnerNickname ? ` by @${entityOwnerNickname}` : '';
return `${quotedTitle}${shortIdPart}${ownerPart}`;
}
// Similar logic for albums...
}
Data Layer & Hooks
useReports Hook
File: src/hooks/useReports.ts
React Query hooks for reports:
useReportsForReview: Fetches reports with status filter, includes reporter and reviewer joinsuseReportCounts: Fetches counts for each status (pending/resolved/dismissed)useResolveReport: Mutation for resolving/dismissing single reportuseBulkResolveReports: Mutation for bulk resolve/dismiss actions- Both mutations invalidate
['reports']and['report-counts']query keys
Data Layer
File: src/lib/data/reports.ts
Server-side data fetching functions:
getReportsForReview: Fetches reports with entity details, reporter info, reviewer infogetReportCounts: Aggregates counts by status- Handles joins to profiles, photos, albums based on entity_type
UI Consistency: Sentence Case
Admin Page Titles
Converted all admin page titles to sentence case:
- "Manage Members" → "Manage members"
- "Admin Tools" → "Admin tools"
- "Admin Dashboard" → "Admin dashboard" (already correct)
Files modified:
src/app/admin/members/page.tsxsrc/app/admin/tools/page.tsx- All other admin pages already used sentence case
Form Labels
Converted form labels to sentence case:
- "Event Title *" → "Event title *"
- "URL Slug *" → "URL slug *"
- "Prompt / Description *" → "Prompt / description *"
- "Cover Image" → "Cover image"
- "Change Image" → "Change image"
Files modified:
src/components/admin/EventForm.tsxsrc/components/admin/EventCoverUpload.tsxsrc/app/admin/challenges/[slug]/page.tsx
Button Labels
- "Accept All" → "Accept all"
File: src/app/admin/challenges/[slug]/submissions/page.tsx
Header Standardization
Standardized header styling across all admin pages to match challenges page:
h1:text-2xl sm:text-3xl font-boldp:text-base sm:text-lg mt-2 text-foreground/70
Files modified:
- All admin pages (already standardized in previous update)
Component Updates
PhotoListItem
File: src/components/manage/PhotoListItem.tsx
- Updated to display
created_atdate instead of filename - Added support for
classNameprop to allow vertical centering (items-center) - Uses
clsxto merge classes correctly, allowing override of defaultitems-start
const hasItemsOverride = className.includes('items-center') || className.includes('items-end');
const baseClasses = clsx(
'flex gap-2 border border-border-color bg-background-medium p-0',
hasItemsOverride ? '' : 'items-start',
className,
);
AlbumMiniCard
File: src/components/album/AlbumMiniCard.tsx
- Added
createdAtprop to display creation date - Added
classNameprop for flexible styling (used for right padding in ReportModal) - Removed slug from title display
Avatar Component
File: src/components/auth/Avatar.tsx
- Added fallback for
sizeConfigto prevent undefined errors:
const sizeConfig = SIZE_MAP[size] || SIZE_MAP.md;
Comments Component
File: src/components/shared/Comments.tsx
- Added
ReportButtonfor reporting individual comments - Uses consistent tiny link styling (
text-xs text-foreground/60)
Revalidation
File: src/app/actions/revalidate.ts
Added revalidation functions for reports:
revalidateReports(): Invalidates reports cache- Called after report submission and resolution
Documentation
Changelog Command Consolidation
File: .cursor/commands/update-changelog.md
- Consolidated duplicate
commands/update-changelog.mdinto.cursor/commands/update-changelog.md - Kept the more comprehensive version with automatic git analysis
- Added step 5: Check and update README.md with changes/roadmap
File: commands/update-changelog.md
- Deleted duplicate file
README Updates
File: README.md
- Updated Moderation section: Changed "Report content" from
[ ]to[x](completed) - Feature will be documented in Features section in next update
All Modified Files
New files (15):
supabase/migrations/20260203000000_add_reports.sqlsrc/app/admin/reports/page.tsxsrc/app/api/reports/route.tssrc/app/api/reports/notify/route.tssrc/app/api/reports/resolved/notify/route.tssrc/components/admin/ResolveReportModal.tsxsrc/components/shared/ReportButton.tsxsrc/components/shared/ReportModal.tsxsrc/emails/report-notification.tsxsrc/emails/report-resolved.tsxsrc/hooks/useReports.tssrc/lib/data/reports.tssrc/types/reports.tspublic/icons/content.svg.cursor/commands/update-changelog.md
Modified files (30):
README.mdsrc/app/[nickname]/album/[albumSlug]/AlbumContent.tsxsrc/app/[nickname]/page.tsxsrc/app/actions/revalidate.tssrc/app/admin/challenges/[slug]/page.tsxsrc/app/admin/challenges/[slug]/submissions/page.tsxsrc/app/admin/challenges/page.tsxsrc/app/admin/events/[eventId]/page.tsxsrc/app/admin/events/attendance/[eventId]/page.tsxsrc/app/admin/events/page.tsxsrc/app/admin/members/page.tsxsrc/app/admin/page.tsxsrc/app/admin/tools/page.tsxsrc/app/api/test/cleanup/route.tssrc/components/admin/EventCoverUpload.tsxsrc/components/admin/EventForm.tsxsrc/components/album/AlbumMiniCard.tsxsrc/components/auth/Avatar.tsxsrc/components/layout/Header.tsxsrc/components/manage/PhotoListItem.tsxsrc/components/photo/PhotoPageContent.tsxsrc/components/shared/Comments.tsxsrc/database.types.tssrc/types/notifications.tssupabase/storage-policies.sql
Deleted files (1):
commands/update-changelog.md