diff --git a/src/shared/composites/ListCard/ListCard.stories.tsx b/src/shared/composites/ListCard/ListCard.stories.tsx new file mode 100644 index 0000000..b7c7883 --- /dev/null +++ b/src/shared/composites/ListCard/ListCard.stories.tsx @@ -0,0 +1,164 @@ +import { useState } from 'react' + +import type { Meta, StoryObj } from '@storybook/react-vite' + +import { ListCard } from './ListCard' + +const meta = { + title: 'Shared/Composites/ListCard', + + component: ListCard, + + tags: ['autodocs'], +} satisfies Meta + +export default meta + +type Story = StoryObj + +const header = ( +
+
Report title
+ +
Additional metadata
+
+) + +const body =
Expanded body content
+ +const footer = ( + <> + + + + +) + +export const Collapsed: Story = { + args: { + header, + + children: body, + + isExpanded: false, + }, +} + +export const Expanded: Story = { + args: { + header, + + children: body, + + isExpanded: true, + }, +} + +export const ExpandedWithFooter: Story = { + args: { + header, + + children: body, + + footer, + + isExpanded: true, + }, +} + +export const HoverState: Story = { + args: { + header, + children: body, + }, + render: () => ( + + {body} + + ), +} + +export const Expandable: Story = { + args: { + header, + children: body, + }, + render: () => { + const [expanded, setExpanded] = useState(false) + + return ( + setExpanded((prev) => !prev)} + > + {body} + + ) + }, +} + +export const NoToggle: Story = { + args: { + header, + + children: body, + + isExpanded: true, + + isClickable: false, + }, +} + +export const FooterActionsDoNotCollapse: Story = { + args: { + header, + children: body, + }, + + render: () => { + const [expanded, setExpanded] = useState(true) + + return ( + + + + + + } + isClickable + isExpanded={expanded} + onToggle={() => { + setExpanded((prev) => !prev) + }} + > + {body} + + ) + }, +} diff --git a/src/shared/composites/ListCard/ListCard.tsx b/src/shared/composites/ListCard/ListCard.tsx new file mode 100644 index 0000000..267f16c --- /dev/null +++ b/src/shared/composites/ListCard/ListCard.tsx @@ -0,0 +1,76 @@ +import React from 'react' + +import { BaseCard } from '@/shared/primitives/BaseCard' + +export interface ListCardProps { + children: React.ReactNode + + header: React.ReactNode + + footer?: React.ReactNode + + isClickable?: boolean + + isExpanded?: boolean + + onToggle?: () => void + + className?: string + + testId?: string +} + +export function ListCard({ + children, + + header, + + footer, + + isClickable = false, + + isExpanded = false, + + onToggle, +}: ListCardProps) { + const handleToggle = () => { + if (!isClickable || !onToggle) { + return + } + + onToggle() + } + + return ( + { + if (isClickable && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + + handleToggle() + } + }} + > +
{header}
+ + {isExpanded && ( + <> +
{children}
+ + {footer && ( +
e.stopPropagation()} + className="border-border-tertiary flex flex-wrap gap-2 border-t p-4" + > + {footer} +
+ )} + + )} +
+ ) +} + +export default ListCard diff --git a/src/shared/composites/ListCard/index.ts b/src/shared/composites/ListCard/index.ts new file mode 100644 index 0000000..facd41b --- /dev/null +++ b/src/shared/composites/ListCard/index.ts @@ -0,0 +1,3 @@ +export * from './ListCard' + +export { default } from './ListCard' diff --git a/src/shared/composites/ReportCard/ReportCard.stories.tsx b/src/shared/composites/ReportCard/ReportCard.stories.tsx new file mode 100644 index 0000000..cebe907 --- /dev/null +++ b/src/shared/composites/ReportCard/ReportCard.stories.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react' + +import type { Meta, StoryObj } from '@storybook/react-vite' + +import { ReportCard } from './ReportCard' + +const meta = { + title: 'Shared/composites/ReportCard', + + component: ReportCard, + + tags: ['autodocs'], +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Expanded: Story = { + args: { + useDummyData: true, + + isExpanded: true, + + onAction: (reportId, action) => { + console.log(reportId, action) + }, + + onToggleExpand: () => {}, + }, +} + +export const Collapsed: Story = { + args: { + useDummyData: true, + + isExpanded: false, + + onAction: (reportId, action) => { + console.log(reportId, action) + }, + + onToggleExpand: () => {}, + }, +} + +export const Interactive: Story = { + args: { + useDummyData: true, + isExpanded: false, + onToggleExpand: () => {}, + onAction: () => {}, + }, + render: () => { + const [expanded, setExpanded] = useState(false) + + return ( + setExpanded(!expanded)} + onAction={(reportId, action) => { + console.log(reportId, action) + }} + /> + ) + }, +} + +export const Claimed: Story = { + args: { + useDummyData: true, + + isExpanded: true, + + isClaimed: true, + + claimedBy: 'Moderator Jane', + + onAction: (reportId, action) => { + console.log(reportId, action) + }, + + onToggleExpand: () => {}, + }, +} diff --git a/src/shared/composites/ReportCard/ReportCard.tsx b/src/shared/composites/ReportCard/ReportCard.tsx new file mode 100644 index 0000000..1b9d488 --- /dev/null +++ b/src/shared/composites/ReportCard/ReportCard.tsx @@ -0,0 +1,309 @@ +import React from 'react' + +import { Badge } from '@/shared/primitives/Badge' + +import { Button } from '@/shared/primitives/Button' + +import { ProgressBar } from '@/shared/primitives/Progressbar' + +import { ListCard } from '@/shared/composites/ListCard' + +import { reports, deriveAvailableActions } from './dummyData' + +export type ReportStatus = 'PENDING' | 'ESCALATED_TO_HUMAN' | 'RESOLVED' | 'DISMISSED' + +export type ReportTargetType = 'POST' | 'COMMENT' | 'USER' + +export type ModerationAction = + | 'RUN_AI_SCREENING' + | 'REMOVE_CONTENT' + | 'BAN_AUTHOR' + | 'WARN_AUTHOR' + | 'DISMISS' + +export interface AuditTrailEntry { + id: string + + actorId: string + + action: string + + timestamp: string +} + +export interface Report { + id: string + + title: string + + description: string + + status: ReportStatus + + targetType: ReportTargetType + + reportReason: string + + aiConfidenceScore: number + + createdAt: string + + reporter: { + id: string + name: string + } + + author: { + id: string + name: string + priorReportCount: number + } + + auditTrail: AuditTrailEntry[] +} + +export interface ReportCardProps { + report?: Report + + isExpanded: boolean + + onToggleExpand: () => void + + onAction: (reportId: string, action: ModerationAction) => void + + isClaimed?: boolean + + claimedBy?: string + + useDummyData?: boolean +} + +function getStatusBadgeVariant(status: ReportStatus): 'danger' | 'warning' | 'success' | 'default' { + switch (status) { + case 'ESCALATED_TO_HUMAN': + return 'danger' + + case 'PENDING': + return 'warning' + + case 'RESOLVED': + return 'success' + + default: + return 'default' + } +} + +function formatStatus(status: ReportStatus) { + switch (status) { + case 'ESCALATED_TO_HUMAN': + return 'Escalated' + + case 'PENDING': + return 'Pending' + + case 'RESOLVED': + return 'Resolved' + + case 'DISMISSED': + return 'Dismissed' + + default: + return status + } +} + +function formatActionLabel(action: ModerationAction) { + switch (action) { + case 'RUN_AI_SCREENING': + return 'Run AI screening' + + case 'REMOVE_CONTENT': + return 'Remove content' + + case 'BAN_AUTHOR': + return 'Ban author' + + case 'WARN_AUTHOR': + return 'Warn author' + + case 'DISMISS': + return 'Dismiss' + + default: + return action + } +} + +function getButtonVariant( + action: ModerationAction +): 'primary' | 'danger' | 'warning' | 'info' | 'secondary' { + switch (action) { + case 'REMOVE_CONTENT': + return 'danger' + + case 'BAN_AUTHOR': + return 'danger' + + case 'WARN_AUTHOR': + return 'warning' + + case 'DISMISS': + return 'secondary' + + case 'RUN_AI_SCREENING': + return 'info' + + default: + return 'primary' + } +} +function StatusPipeline() { + const stages = ['Pending', 'AI screening', 'Escalated', 'Resolved'] + + return ( +
+ {stages.map((stage) => ( + + + {stage} + + + {stage !== 'Resolved' && } + + ))} +
+ ) +} + +export function ReportCard({ + report, + + isExpanded, + + onToggleExpand, + + onAction, + + isClaimed = false, + + claimedBy, + + useDummyData = false, +}: ReportCardProps) { + const resolvedReport = useDummyData ? reports[0] : report + + if (!resolvedReport) { + return null + } + + const availableActions = deriveAvailableActions(resolvedReport, isClaimed) + + return ( + +
+
+ {resolvedReport.id} + + + {formatStatus(resolvedReport.status)} + + + {resolvedReport.targetType} +
+
+ {' '} + + {resolvedReport.reportReason} + +
+
+ Reported by {resolvedReport.reporter.name} · AI score:{' '} + {resolvedReport.aiConfidenceScore} +
+
+ +
+ +
+ + } + footer={ + isClaimed ? ( +
🔒 Being reviewed by {claimedBy}
+ ) : ( + <> + {availableActions.map((action) => ( + + ))} + + ) + } + > +
+ + +
+
+ Reporter: {resolvedReport.reporter.name} +
+ +
+ Author: {resolvedReport.author.name} +
+ +
+ Prior reports:{' '} + {resolvedReport.author.priorReportCount} +
+
+ +
+
Content
+ +
+ "{resolvedReport.description}" +
+
+ +
+
Audit Trail
+ +
+ {resolvedReport.auditTrail.map((entry) => ( +
+ {entry.timestamp} · {entry.action} +
+ ))} +
+
+
+
+ ) +} + +export default ReportCard diff --git a/src/shared/composites/ReportCard/dummyData.ts b/src/shared/composites/ReportCard/dummyData.ts new file mode 100644 index 0000000..c4eb69c --- /dev/null +++ b/src/shared/composites/ReportCard/dummyData.ts @@ -0,0 +1,457 @@ +import type { ModerationAction, Report } from '@/shared/types/report' + +export const reports: Report[] = [ + { + id: 'RPT-0001', + + title: 'Potential hate speech', + + description: 'These people should not exist.', + + status: 'ESCALATED_TO_HUMAN', + + targetType: 'COMMENT', + + reportReason: 'Hate speech', + + aiConfidenceScore: 0.94, + + createdAt: '2026-05-18T09:12:01Z', + + reporter: { + id: 'u101', + name: 'Alice', + }, + + author: { + id: 'u201', + name: 'Mark', + priorReportCount: 5, + }, + + auditTrail: [ + { + id: 'a1', + actorId: 'u101', + action: 'Report submitted', + timestamp: '2026-05-18T09:12:01Z', + }, + ], + }, + + { + id: 'RPT-0002', + + title: 'Spam promotion', + + description: 'Click this link for free crypto rewards!', + + status: 'PENDING', + + targetType: 'POST', + + reportReason: 'Spam', + + aiConfidenceScore: 0.71, + + createdAt: '2026-05-18T10:00:11Z', + + reporter: { + id: 'u102', + name: 'Brian', + }, + + author: { + id: 'u202', + name: 'Jake', + priorReportCount: 1, + }, + + auditTrail: [], + }, + + { + id: 'RPT-0003', + + title: 'Potential misinformation', + + description: 'This is fake news, scam alert!', + + status: 'ESCALATED_TO_HUMAN', + + targetType: 'COMMENT', + + reportReason: 'Misinformation', + + aiConfidenceScore: 0.65, + + createdAt: '2026-05-18T14:02:01Z', + + reporter: { + id: 'u003', + name: 'Carol', + }, + + author: { + id: 'u002', + name: 'Bob', + priorReportCount: 2, + }, + + auditTrail: [ + { + id: 'a3', + actorId: 'u003', + action: 'Report submitted', + timestamp: '2026-05-18T14:02:01Z', + }, + ], + }, + + { + id: 'RPT-0004', + + title: 'Harassment report', + + description: 'User repeatedly sending abusive DMs.', + + status: 'ESCALATED_TO_HUMAN', + + targetType: 'USER', + + reportReason: 'Harassment', + + aiConfidenceScore: 0.88, + + createdAt: '2026-05-18T11:22:41Z', + + reporter: { + id: 'u104', + name: 'Diana', + }, + + author: { + id: 'u204', + name: 'Chris', + priorReportCount: 8, + }, + + auditTrail: [], + }, + + { + id: 'RPT-0005', + + title: 'Resolved impersonation case', + + description: 'Fake account pretending to be a public figure.', + + status: 'RESOLVED', + + targetType: 'USER', + + reportReason: 'Impersonation', + + aiConfidenceScore: 0.92, + + createdAt: '2026-05-17T18:00:00Z', + + reporter: { + id: 'u105', + name: 'Emily', + }, + + author: { + id: 'u205', + name: 'FakeElon', + priorReportCount: 12, + }, + + auditTrail: [], + }, + + { + id: 'RPT-0006', + + title: 'Graphic violence content', + + description: 'Disturbing violent imagery uploaded.', + + status: 'PENDING', + + targetType: 'POST', + + reportReason: 'Violence', + + aiConfidenceScore: 0.84, + + createdAt: '2026-05-18T12:45:19Z', + + reporter: { + id: 'u106', + name: 'Frank', + }, + + author: { + id: 'u206', + name: 'Ron', + priorReportCount: 0, + }, + + auditTrail: [], + }, + + { + id: 'RPT-0007', + + title: 'Dismissed spam report', + + description: 'Repeated emojis in comments.', + + status: 'DISMISSED', + + targetType: 'COMMENT', + + reportReason: 'Spam', + + aiConfidenceScore: 0.22, + + createdAt: '2026-05-17T08:14:55Z', + + reporter: { + id: 'u107', + name: 'Grace', + }, + + author: { + id: 'u207', + name: 'Tom', + priorReportCount: 0, + }, + + auditTrail: [], + }, + + { + id: 'RPT-0008', + + title: 'Suspicious phishing attempt', + + description: 'Please login with your bank details here.', + + status: 'ESCALATED_TO_HUMAN', + + targetType: 'POST', + + reportReason: 'Phishing', + + aiConfidenceScore: 0.97, + + createdAt: '2026-05-18T15:05:41Z', + + reporter: { + id: 'u108', + name: 'Henry', + }, + + author: { + id: 'u208', + name: 'ScamKing', + priorReportCount: 15, + }, + + auditTrail: [], + }, + + { + id: 'RPT-0009', + + title: 'Self-harm concern', + + description: 'User expressing dangerous thoughts.', + + status: 'PENDING', + + targetType: 'POST', + + reportReason: 'Self-harm', + + aiConfidenceScore: 0.79, + + createdAt: '2026-05-18T16:22:11Z', + + reporter: { + id: 'u109', + name: 'Irene', + }, + + author: { + id: 'u209', + name: 'Sam', + priorReportCount: 0, + }, + + auditTrail: [], + }, + + { + id: 'RPT-0010', + + title: 'Unscreeened report', + + description: 'Unknown suspicious activity.', + + status: 'PENDING', + + targetType: 'COMMENT', + + reportReason: 'Suspicious activity', + + aiConfidenceScore: 0, + + createdAt: '2026-05-18T17:10:33Z', + + reporter: { + id: 'u110', + name: 'Kevin', + }, + + author: { + id: 'u210', + name: 'UnknownUser', + priorReportCount: 0, + }, + + auditTrail: [], + }, + + { + id: 'RPT-0011', + + title: 'Resolved nudity report', + + description: 'Explicit content uploaded publicly.', + + status: 'RESOLVED', + + targetType: 'POST', + + reportReason: 'Adult content', + + aiConfidenceScore: 0.89, + + createdAt: '2026-05-16T10:00:00Z', + + reporter: { + id: 'u111', + name: 'Laura', + }, + + author: { + id: 'u211', + name: 'NSFWPoster', + priorReportCount: 6, + }, + + auditTrail: [], + }, + + { + id: 'RPT-0012', + + title: 'Threatening language', + + description: 'I know where you live.', + + status: 'ESCALATED_TO_HUMAN', + + targetType: 'COMMENT', + + reportReason: 'Threats', + + aiConfidenceScore: 0.91, + + createdAt: '2026-05-18T19:20:14Z', + + reporter: { + id: 'u112', + name: 'Nathan', + }, + + author: { + id: 'u212', + name: 'ThreatGuy', + priorReportCount: 11, + }, + + auditTrail: [], + }, + + { + id: 'RPT-0013', + + title: 'False medical advice', + + description: 'Drink bleach to cure illness.', + + status: 'PENDING', + + targetType: 'POST', + + reportReason: 'Medical misinformation', + + aiConfidenceScore: 0.83, + + createdAt: '2026-05-18T20:11:07Z', + + reporter: { + id: 'u113', + name: 'Olivia', + }, + + author: { + id: 'u213', + name: 'FakeDoctor', + priorReportCount: 4, + }, + + auditTrail: [], + }, +] + +const ACTION_ORDER: ModerationAction[] = [ + 'REMOVE_CONTENT', + 'BAN_AUTHOR', + 'WARN_AUTHOR', + 'DISMISS', + 'RUN_AI_SCREENING', +] + +export function deriveAvailableActions(report: Report, isClaimed?: boolean): ModerationAction[] { + if (isClaimed || report.status === 'RESOLVED' || report.status === 'DISMISSED') { + return [] + } + + if (report.status === 'PENDING' && report.aiConfidenceScore === 0) { + return ['RUN_AI_SCREENING'] + } + + const actions: ModerationAction[] = [] + + if (report.status === 'PENDING' || report.status === 'ESCALATED_TO_HUMAN') { + actions.push('WARN_AUTHOR', 'DISMISS') + } + + if ( + (report.status === 'PENDING' || report.status === 'ESCALATED_TO_HUMAN') && + report.targetType !== 'USER' + ) { + actions.push('REMOVE_CONTENT') + } + + if (report.status === 'ESCALATED_TO_HUMAN') { + actions.push('BAN_AUTHOR') + } + + return actions.sort((a, b) => ACTION_ORDER.indexOf(a) - ACTION_ORDER.indexOf(b)) +} diff --git a/src/shared/composites/ReportCard/index.ts b/src/shared/composites/ReportCard/index.ts new file mode 100644 index 0000000..883c54f --- /dev/null +++ b/src/shared/composites/ReportCard/index.ts @@ -0,0 +1,3 @@ +export * from './ReportCard' + +export { default } from './ReportCard' diff --git a/src/shared/types/report.ts b/src/shared/types/report.ts new file mode 100644 index 0000000..1c082d2 --- /dev/null +++ b/src/shared/types/report.ts @@ -0,0 +1,51 @@ +export type ReportStatus = 'PENDING' | 'ESCALATED_TO_HUMAN' | 'RESOLVED' | 'DISMISSED' + +export type ReportTargetType = 'POST' | 'COMMENT' | 'USER' + +export type ModerationAction = + | 'RUN_AI_SCREENING' + | 'REMOVE_CONTENT' + | 'BAN_AUTHOR' + | 'WARN_AUTHOR' + | 'DISMISS' + +export interface AuditTrailEntry { + id: string + + actorId: string + + action: string + + timestamp: string +} + +export interface Report { + id: string + + title: string + + description: string + + status: ReportStatus + + targetType: ReportTargetType + + reportReason: string + + aiConfidenceScore: number + + createdAt: string + + reporter: { + id: string + name: string + } + + author: { + id: string + name: string + priorReportCount: number + } + + auditTrail: AuditTrailEntry[] +} diff --git a/src/views/LoginPage.tsx b/src/views/LoginPage.tsx index 22b4c3b..53322f4 100644 --- a/src/views/LoginPage.tsx +++ b/src/views/LoginPage.tsx @@ -1,17 +1,11 @@ // import LoginCard from '@/LoginCard' import MetricCard from '@/shared/composites/MetricCard' -// import { SidebarNav } from '@/shared/composites/SidebarNav' +// import { ChipGroup } from '@/Chip' -// import { -// CheckIcon, -// ExclamationTriangleIcon, -// PresentationChartLineIcon, -// QueueListIcon, -// UsersIcon, -// } from '@heroicons/react/24/outline' +// import ReportCard from '@/shared/composites/ReportCard' -// import { useState } from 'react' +// import { reports } from '@/shared/composites/ReportCard/dummyData' const LoginPage = () => { const metricCardData = [ diff --git a/src/views/LoginPage1.tsx b/src/views/LoginPage1.tsx new file mode 100644 index 0000000..a095d9a --- /dev/null +++ b/src/views/LoginPage1.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react' + +import { ChipGroup } from '@/Chip' + +import ReportCard from '@/shared/composites/ReportCard' + +import { reports } from '@/shared/composites/ReportCard/dummyData' + +const LoginPage = () => { + const [selected, setSelected] = useState('all') + + const [expandedId, setExpandedId] = useState(null) + + return ( +
+ {/* Header */} + +
+
Active Reports
+ + { + setSelected(value) + + console.log('Selected:', value) + }} + options={[ + { + label: 'All', + value: 'all', + }, + + { + label: 'Escalated', + value: 'escalated', + count: 1, + }, + + { + label: 'Spam', + value: 'spam', + }, + + { + label: 'Hate speech', + value: 'hate-speech', + }, + + { + label: 'Misinformation', + value: 'misinformation', + }, + ]} + /> +
+ + {/* Report cards */} +
+ {reports.map((report) => ( + { + setExpandedId(expandedId === report.id ? null : report.id) + }} + onAction={(reportId, action) => { + console.log('Action:', action, 'on', reportId) + }} + /> + ))} +
+
+ ) +} + +export default LoginPage