1+ import Link from "next/link"
2+
3+ import { StatusBadge } from "@/components/elements/StatusBadge"
4+ import { Alert , AlertDescription } from "@/components/ui/alert"
5+ import { Button , buttonVariants } from "@/components/ui/button"
6+ import { Card , CardContent } from "@/components/ui/card"
7+ import type { Content } from "@/lib/types"
8+ import { cn } from "@/lib/utils"
9+ import { formatDate , formatPercentScore , truncateText } from "@/lib/view-helpers"
10+
11+ import type { ContentClusterBadge } from "../shared"
12+
13+ type ContentFeedProps = {
14+ projectId : number
15+ filteredContents : Content [ ]
16+ contentClusterLookup : Map < number , ContentClusterBadge >
17+ }
18+
19+ /** Render the surfaced content cards and quick editorial actions. */
20+ export function ContentFeed ( {
21+ projectId,
22+ filteredContents,
23+ contentClusterLookup,
24+ } : ContentFeedProps ) {
25+ return (
26+ < div className = "space-y-4" >
27+ { filteredContents . length === 0 ? (
28+ < Alert className = "rounded-panel border-border/10 bg-muted/60" >
29+ < AlertDescription > No content matched the current filters.</ AlertDescription >
30+ </ Alert >
31+ ) : null }
32+ { filteredContents . map ( ( content ) => {
33+ const trendCluster = contentClusterLookup . get ( content . id )
34+
35+ return (
36+ < Card
37+ className = "rounded-3xl border border-border/12 bg-card/85 shadow-panel backdrop-blur-xl"
38+ key = { content . id }
39+ >
40+ < CardContent className = "grid gap-4 p-5" >
41+ < div className = "flex flex-col gap-4 md:flex-row md:items-start md:justify-between" >
42+ < div className = "space-y-3" >
43+ < h3 className = "font-display text-title-md font-bold" > { content . title } </ h3 >
44+ < div className = "flex flex-wrap gap-2 text-sm text-muted" >
45+ < span > { formatDate ( content . published_date ) } </ span >
46+ < span > { content . author || "Unknown author" } </ span >
47+ < span > { content . source_plugin } </ span >
48+ </ div >
49+ </ div >
50+ < StatusBadge
51+ tone = {
52+ ( content . authority_adjusted_score ?? content . relevance_score ?? 0 ) >= 0.7
53+ ? "positive"
54+ : "warning"
55+ }
56+ >
57+ Adjusted { formatPercentScore ( content . authority_adjusted_score ?? content . relevance_score ) }
58+ </ StatusBadge >
59+ </ div >
60+
61+ < div className = "flex flex-wrap gap-2" >
62+ { trendCluster ? (
63+ < Link
64+ className = { cn ( buttonVariants ( { size : "sm" , variant : "secondary" } ) , "rounded-full px-3" ) }
65+ href = { `/trends?project=${ projectId } &cluster=${ trendCluster . clusterId } ` }
66+ >
67+ Trend { trendCluster . label } · { formatPercentScore ( trendCluster . velocityScore ?? null ) }
68+ </ Link >
69+ ) : null }
70+ { content . authority_adjusted_score !== null ? (
71+ < span className = "inline-flex items-center rounded-full border border-primary/18 bg-primary/8 px-3 py-1 text-sm text-foreground" >
72+ Base { formatPercentScore ( content . relevance_score ) }
73+ </ span >
74+ ) : null }
75+ < span className = "inline-flex items-center rounded-full border border-border/12 bg-muted/55 px-3 py-1 text-sm text-foreground" >
76+ { content . content_type || "unclassified" }
77+ </ span >
78+ { content . duplicate_signal_count > 0 ? (
79+ < span className = "inline-flex items-center rounded-full border border-border/12 bg-muted/55 px-3 py-1 text-sm text-foreground" >
80+ Also seen in { content . duplicate_signal_count } source
81+ { content . duplicate_signal_count === 1 ? "" : "s" }
82+ </ span >
83+ ) : null }
84+ { content . duplicate_of ? (
85+ < span className = "inline-flex items-center rounded-full border border-border/12 bg-muted/55 px-3 py-1 text-sm text-foreground" >
86+ Duplicate of #{ content . duplicate_of }
87+ </ span >
88+ ) : null }
89+ { content . is_reference ? (
90+ < span className = "inline-flex items-center rounded-full border border-border/12 bg-muted/55 px-3 py-1 text-sm text-foreground" > reference</ span >
91+ ) : null }
92+ { ! content . is_active ? (
93+ < span className = "inline-flex items-center rounded-full border border-border/12 bg-muted/55 px-3 py-1 text-sm text-foreground" > archived</ span >
94+ ) : null }
95+ { content . newsletter_promotion_at ? (
96+ < Link
97+ className = { cn ( buttonVariants ( { size : "sm" , variant : "secondary" } ) , "rounded-full px-3" ) }
98+ href = { content . newsletter_promotion_theme ? `/themes?project=${ projectId } &theme=${ content . newsletter_promotion_theme } ` : `/themes?project=${ projectId } ` }
99+ >
100+ Promoted { formatDate ( content . newsletter_promotion_at ) }
101+ </ Link >
102+ ) : null }
103+ </ div >
104+
105+ < p className = "text-sm leading-6 text-muted" > { truncateText ( content . content_text ) } </ p >
106+
107+ < div className = "flex flex-wrap items-center gap-3" >
108+ < Link
109+ className = { cn ( buttonVariants ( { size : "lg" } ) , "min-h-11 rounded-full px-4 py-3" ) }
110+ href = { `/content/${ content . id } ?project=${ projectId } ` }
111+ >
112+ Open detail
113+ </ Link >
114+ < form action = "/api/feedback" method = "POST" >
115+ < input name = "projectId" type = "hidden" value = { projectId } />
116+ < input name = "contentId" type = "hidden" value = { content . id } />
117+ < input name = "feedbackType" type = "hidden" value = "upvote" />
118+ < input name = "redirectTo" type = "hidden" value = { `/?project=${ projectId } ` } />
119+ < Button className = "min-h-11 rounded-full px-4 py-3" size = "lg" type = "submit" >
120+ Upvote
121+ </ Button >
122+ </ form >
123+ < form action = "/api/feedback" method = "POST" >
124+ < input name = "projectId" type = "hidden" value = { projectId } />
125+ < input name = "contentId" type = "hidden" value = { content . id } />
126+ < input name = "feedbackType" type = "hidden" value = "downvote" />
127+ < input name = "redirectTo" type = "hidden" value = { `/?project=${ projectId } ` } />
128+ < Button className = "min-h-11 rounded-full px-4 py-3" size = "lg" type = "submit" variant = "outline" >
129+ Downvote
130+ </ Button >
131+ </ form >
132+ </ div >
133+ </ CardContent >
134+ </ Card >
135+ )
136+ } ) }
137+ </ div >
138+ )
139+ }
0 commit comments