Skip to content

Commit 4658bcf

Browse files
committed
Last batch of shadcn refactors
1 parent 9f97725 commit 4658bcf

224 files changed

Lines changed: 11270 additions & 4271 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎frontend/package.json‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
"lint:fix": "eslint . --fix",
1212
"format": "prettier --write .",
1313
"format:check": "prettier --check .",
14-
"test": "vitest",
15-
"test:run": "vitest run",
16-
"test:coverage": "vitest run --coverage",
14+
"test": "vitest --project=unit",
15+
"test:run": "vitest run --project=unit",
16+
"test:coverage": "vitest run --project=unit --coverage",
17+
"test:all": "vitest run",
18+
"test:storybook": "vitest run --project=storybook",
1719
"storybook": "storybook dev -p 6006 --no-open",
1820
"build-storybook": "storybook build"
1921
},
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { Meta, StoryObj } from "@storybook/nextjs-vite"
2+
3+
import { compactDocsParameters } from "@/lib/storybook-docs"
4+
import { createContent } from "@/lib/storybook-fixtures"
5+
6+
import { ContentFeed } from "."
7+
8+
const content = createContent({
9+
is_reference: true,
10+
newsletter_promotion_at: "2026-04-28T11:00:00Z",
11+
newsletter_promotion_theme: 14,
12+
})
13+
14+
const meta = {
15+
title: "Pages/Home/Components/ContentFeed",
16+
component: ContentFeed,
17+
tags: ["autodocs"],
18+
parameters: {
19+
docs: compactDocsParameters,
20+
},
21+
args: {
22+
projectId: 1,
23+
filteredContents: [content],
24+
contentClusterLookup: new Map([
25+
[content.id, { clusterId: 5, label: "Platform Signals", velocityScore: 0.81 }],
26+
]),
27+
},
28+
} satisfies Meta<typeof ContentFeed>
29+
30+
export default meta
31+
32+
type Story = StoryObj<typeof meta>
33+
34+
export const Default: Story = {}
35+
36+
export const Empty: Story = {
37+
args: {
38+
filteredContents: [],
39+
contentClusterLookup: new Map(),
40+
},
41+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { render, screen } from "@testing-library/react"
2+
import { describe, expect, it } from "vitest"
3+
4+
import { createContent } from "@/lib/storybook-fixtures"
5+
6+
import { ContentFeed } from "."
7+
8+
describe("ContentFeed", () => {
9+
it("renders the empty state when no content matches", () => {
10+
render(<ContentFeed contentClusterLookup={new Map()} filteredContents={[]} projectId={1} />)
11+
12+
expect(screen.getByText("No content matched the current filters.")).toBeInTheDocument()
13+
})
14+
15+
it("renders content cards, trend badges, and quick actions", () => {
16+
const content = createContent({
17+
is_reference: true,
18+
newsletter_promotion_at: "2026-04-28T11:00:00Z",
19+
newsletter_promotion_theme: 14,
20+
})
21+
22+
render(
23+
<ContentFeed
24+
contentClusterLookup={
25+
new Map([
26+
[
27+
content.id,
28+
{ clusterId: 5, label: "Platform Signals", velocityScore: 0.81 },
29+
],
30+
])
31+
}
32+
filteredContents={[content]}
33+
projectId={1}
34+
/>,
35+
)
36+
37+
expect(screen.getByText(content.title)).toBeInTheDocument()
38+
expect(screen.getByRole("link", { name: /Trend Platform Signals/i })).toHaveAttribute(
39+
"href",
40+
"/trends?project=1&cluster=5",
41+
)
42+
expect(screen.getByText("Base 84%")).toBeInTheDocument()
43+
expect(screen.getByText("reference")).toBeInTheDocument()
44+
expect(screen.getByRole("button", { name: "Upvote" })).toBeInTheDocument()
45+
})
46+
})
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { Meta, StoryObj } from "@storybook/nextjs-vite"
2+
3+
import { compactDocsParameters } from "@/lib/storybook-docs"
4+
5+
import { DashboardFilterToolbar } from "."
6+
7+
const meta = {
8+
title: "Pages/Home/Components/DashboardFilterToolbar",
9+
component: DashboardFilterToolbar,
10+
tags: ["autodocs"],
11+
parameters: {
12+
docs: compactDocsParameters,
13+
},
14+
args: {
15+
projectId: 1,
16+
view: "content",
17+
contentTypes: ["article", "tutorial"],
18+
contentTypeFilter: "",
19+
sources: ["rss", "reddit"],
20+
sourceFilter: "",
21+
daysFilter: 30,
22+
duplicateStateFilter: "",
23+
},
24+
} satisfies Meta<typeof DashboardFilterToolbar>
25+
26+
export default meta
27+
28+
type Story = StoryObj<typeof meta>
29+
30+
export const Default: Story = {}
31+
32+
export const Filtered: Story = {
33+
args: {
34+
contentTypeFilter: "article",
35+
sourceFilter: "rss",
36+
duplicateStateFilter: "duplicate_related",
37+
},
38+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { render, screen } from "@testing-library/react"
2+
import { describe, expect, it } from "vitest"
3+
4+
import { DashboardFilterToolbar } from "."
5+
6+
describe("DashboardFilterToolbar", () => {
7+
it("renders the dashboard filter form", () => {
8+
const { container } = render(
9+
<DashboardFilterToolbar
10+
contentTypeFilter="article"
11+
contentTypes={["article", "podcast"]}
12+
daysFilter={30}
13+
duplicateStateFilter="duplicate_related"
14+
projectId={1}
15+
sourceFilter="rss"
16+
sources={["rss", "reddit"]}
17+
view="content"
18+
/>,
19+
)
20+
21+
expect(screen.getByRole("button", { name: "Apply filters" })).toBeInTheDocument()
22+
expect(screen.getByRole("link", { name: "Reset" })).toHaveAttribute("href", "/?project=1")
23+
expect(container.querySelector('input[name="project"]')).toHaveValue("1")
24+
expect(container.querySelector('input[name="contentType"]')).toHaveValue("article")
25+
expect(container.querySelector('input[name="source"]')).toHaveValue("rss")
26+
expect(container.querySelector('input[name="days"]')).toHaveValue("30")
27+
})
28+
})

0 commit comments

Comments
 (0)