Barazo default frontend
barazo.forum
1/**
2 * TopicView - Displays a full topic post with content and metadata.
3 * Includes reactions, moderation controls, report button, edit button, and self-labels.
4 * Used on the topic detail page.
5 * @see specs/prd-web.md Section 4 (Topic Components)
6 */
7
8import Link from 'next/link'
9import Image from 'next/image'
10import {
11 ChatCircle,
12 Clock,
13 Tag,
14 PencilSimple,
15 Link as LinkIcon,
16} from '@phosphor-icons/react/dist/ssr'
17import type { Topic } from '@/lib/api/types'
18import { cn } from '@/lib/utils'
19import { formatRelativeTime, formatCompactNumber, isEdited } from '@/lib/format'
20import { MarkdownContent } from './markdown-content'
21import { LikeButton } from './like-button'
22import { ReactionBar } from './reaction-bar'
23import {
24 ModerationControls,
25 type ModerationAction,
26 type ModerationActionOptions,
27} from './moderation-controls'
28import { ReportDialog, type ReportSubmission } from './report-dialog'
29import { SelfLabelIndicator } from './self-label-indicator'
30
31interface ReactionData {
32 type: string
33 count: number
34 reacted: boolean
35}
36
37interface TopicViewProps {
38 topic: Topic
39 reactions?: ReactionData[]
40 onReactionToggle?: (type: string) => void
41 isModerator?: boolean
42 isAdmin?: boolean
43 isLocked?: boolean
44 isPinned?: boolean
45 onModerationAction?: (action: ModerationAction, options?: ModerationActionOptions) => void
46 canEdit?: boolean
47 onEdit?: () => void
48 onReply?: () => void
49 canReport?: boolean
50 onReport?: (report: ReportSubmission) => void
51 isOwnContent?: boolean
52 selfLabels?: string[]
53 className?: string
54}
55
56export function TopicView({
57 topic,
58 reactions,
59 onReactionToggle,
60 isModerator,
61 isAdmin,
62 isLocked,
63 isPinned,
64 onModerationAction,
65 canEdit,
66 onEdit,
67 onReply,
68 canReport,
69 onReport,
70 isOwnContent,
71 selfLabels,
72 className,
73}: TopicViewProps) {
74 const headingId = `topic-heading-${topic.rkey}`
75 const isDeleted = topic.isAuthorDeleted || topic.isModDeleted
76
77 if (isDeleted) {
78 const tombstoneText = topic.isModDeleted
79 ? 'This topic was removed by a moderator.'
80 : 'This topic was removed by the author.'
81
82 return (
83 <article
84 id="post-1"
85 className={cn('rounded-lg border border-border bg-muted/50', className)}
86 aria-labelledby={headingId}
87 >
88 <div className="p-4 sm:p-6">
89 <div className="flex items-center gap-2 text-sm">
90 <span
91 className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground"
92 aria-hidden="true"
93 >
94 ?
95 </span>
96 <h2 id={headingId} className="font-medium text-muted-foreground">
97 [deleted]
98 </h2>
99 </div>
100 <p className="mt-4 text-sm italic text-muted-foreground">{tombstoneText}</p>
101 </div>
102 </article>
103 )
104 }
105
106 return (
107 <article
108 id="post-1"
109 className={cn('rounded-lg border border-border bg-card', className)}
110 aria-labelledby={headingId}
111 >
112 {/* Header */}
113 <div className="border-b border-border p-4 sm:p-6">
114 <h2 id={headingId} className="text-xl font-bold text-foreground sm:text-2xl">
115 {topic.title}
116 </h2>
117
118 {/* Author + timestamp */}
119 <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
120 <Link
121 href={`/profile/${topic.author?.handle ?? topic.authorDid}`}
122 className="flex items-center gap-1.5 hover:text-foreground"
123 >
124 {topic.author?.avatarUrl ? (
125 <Image
126 src={topic.author.avatarUrl}
127 alt=""
128 width={24}
129 height={24}
130 className="rounded-full object-cover"
131 />
132 ) : (
133 <span
134 className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium"
135 aria-hidden="true"
136 >
137 {(topic.author?.displayName ?? topic.author?.handle ?? '?')[0]?.toUpperCase()}
138 </span>
139 )}
140 <span>{topic.author?.displayName ?? topic.author?.handle ?? topic.authorDid}</span>
141 </Link>
142 <span aria-hidden="true">·</span>
143 <time dateTime={topic.publishedAt}>{formatRelativeTime(topic.publishedAt)}</time>
144 {isEdited(topic.publishedAt, topic.indexedAt) && (
145 <span
146 className="text-muted-foreground"
147 title={`Edited ${new Date(topic.indexedAt).toLocaleString()}`}
148 >
149 (edited)
150 </span>
151 )}
152 </div>
153
154 {/* Category + Tags */}
155 <div className="mt-3 flex flex-wrap items-center gap-2">
156 <Link
157 href={`/c/${topic.category}`}
158 className="rounded-full bg-primary-muted px-2.5 py-0.5 text-xs font-medium text-primary transition-colors hover:bg-primary hover:text-primary-foreground"
159 >
160 {topic.category}
161 </Link>
162 {topic.tags?.map((tag) => (
163 <Link
164 key={tag}
165 href={`/tag/${tag}`}
166 className="inline-flex items-center gap-1 py-1 text-xs text-muted-foreground hover:text-foreground"
167 >
168 <Tag className="h-3 w-3" weight="regular" aria-hidden="true" />#{tag}
169 </Link>
170 ))}
171 </div>
172
173 {/* Moderation controls */}
174 {isModerator && onModerationAction && (
175 <div className="mt-3">
176 <ModerationControls
177 isModerator={true}
178 isAdmin={isAdmin}
179 isLocked={isLocked}
180 isPinned={isPinned}
181 onAction={onModerationAction}
182 />
183 </div>
184 )}
185 </div>
186
187 {/* Content */}
188 <div className="p-4 sm:p-6">
189 {selfLabels && selfLabels.length > 0 ? (
190 <SelfLabelIndicator labels={selfLabels}>
191 <MarkdownContent content={topic.content} />
192 </SelfLabelIndicator>
193 ) : (
194 <MarkdownContent content={topic.content} />
195 )}
196 </div>
197
198 {/* Footer: read signals left, actions right */}
199 <div className="flex items-center gap-4 border-t border-border px-4 py-3 text-sm text-muted-foreground sm:px-6">
200 {reactions && onReactionToggle && (
201 <ReactionBar reactions={reactions} onToggle={onReactionToggle} disabled={isOwnContent} />
202 )}
203 <LikeButton
204 subjectUri={topic.uri}
205 subjectCid={topic.cid}
206 initialCount={topic.reactionCount}
207 disabled={isOwnContent}
208 />
209 <span className="flex items-center gap-1.5">
210 <Clock className="h-4 w-4" weight="regular" aria-hidden="true" />
211 Last activity {formatRelativeTime(topic.lastActivityAt)}
212 </span>
213
214 <a
215 href="#post-1"
216 className="ml-auto flex items-center gap-1.5 hover:text-foreground"
217 aria-label="Permalink to original post"
218 >
219 <LinkIcon className="h-4 w-4" weight="regular" aria-hidden="true" />
220 </a>
221
222 {canEdit && onEdit && (
223 <button
224 type="button"
225 onClick={onEdit}
226 className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
227 >
228 <PencilSimple className="h-3.5 w-3.5" weight="regular" aria-hidden="true" />
229 Edit
230 </button>
231 )}
232
233 {onReply ? (
234 <button
235 type="button"
236 className="flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
237 aria-label={`Reply to this topic (${formatCompactNumber(topic.replyCount)} replies)`}
238 onClick={onReply}
239 >
240 <ChatCircle className="h-4 w-4" weight="regular" aria-hidden="true" />
241 {formatCompactNumber(topic.replyCount)}
242 </button>
243 ) : (
244 <span
245 className="flex items-center gap-1.5"
246 aria-label={`${formatCompactNumber(topic.replyCount)} replies`}
247 >
248 <ChatCircle className="h-4 w-4" weight="regular" aria-hidden="true" />
249 {formatCompactNumber(topic.replyCount)}
250 </span>
251 )}
252
253 {canReport && onReport && <ReportDialog subjectUri={topic.uri} onSubmit={onReport} />}
254 </div>
255 </article>
256 )
257}