Barazo default frontend barazo.forum
at main 257 lines 8.4 kB view raw
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}