Skip to content

Commit 535de16

Browse files
itexpert120elliotBraemcoderabbitai[bot]
authored
Feat: recent content (#208)
* Feat: add recent feeds * feat: add recent content to the main feed page * fmt * Update apps/app/src/hooks/use-rss-feed.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * nitpicks --------- Co-authored-by: Elliot Braem <elliot@everything.dev> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Elliot Braem <elliot@ejlbraem.com>
1 parent fbfc117 commit 535de16

File tree

13 files changed

+874
-19
lines changed

13 files changed

+874
-19
lines changed

apps/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@tanstack/react-query": "^5.81.2",
3535
"@tanstack/react-router": "^1.121.34",
3636
"@tanstack/react-table": "^8.21.3",
37+
"@tanstack/react-virtual": "^3.13.12",
3738
"@tanstack/zod-form-adapter": "^0.42.1",
3839
"autoprefixer": "^10.4.21",
3940
"class-variance-authority": "^0.7.1",
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { Badge } from "@/components/ui/badge";
2+
import { Card, CardContent } from "@/components/ui/card";
3+
import { RssFeedItem } from "@/types/rss";
4+
5+
interface FeedCardProps {
6+
item: RssFeedItem;
7+
feedName: string;
8+
feedId: string;
9+
feedImage?: string;
10+
}
11+
12+
export function FeedCard({ item, feedName, feedId, feedImage }: FeedCardProps) {
13+
const handleClick = () => {
14+
if (item.link) {
15+
window.open(item.link, "_blank", "noopener,noreferrer");
16+
}
17+
};
18+
19+
return (
20+
<Card
21+
className="cursor-pointer hover:shadow-md transition-shadow h-full flex flex-col"
22+
onClick={handleClick}
23+
>
24+
<CardContent className="p-4 flex flex-col h-full">
25+
{/* Image */}
26+
{item.image && (
27+
<div className="mb-4">
28+
<img
29+
src={item.image}
30+
alt={item.title}
31+
className="w-full h-40 object-cover rounded-md"
32+
onError={(e) => {
33+
e.currentTarget.style.display = "none";
34+
}}
35+
/>
36+
</div>
37+
)}
38+
39+
{/* Content - flex-grow to push footer to bottom */}
40+
<div className="flex-grow flex flex-col">
41+
{/* Title */}
42+
<h4 className="font-semibold text-base line-clamp-3 mb-3 leading-tight">
43+
{item.title}
44+
</h4>
45+
46+
{/* Description */}
47+
{item.description && (
48+
<p className="text-sm text-muted-foreground line-clamp-3 mb-4 flex-grow">
49+
{item.description}
50+
</p>
51+
)}
52+
</div>
53+
54+
{/* Footer Badge - always at bottom */}
55+
<div className="flex items-center gap-2 mt-auto">
56+
<img
57+
src={feedImage || "/images/feed-image.png"}
58+
alt={feedName}
59+
className="w-5 h-5 rounded-full object-cover flex-shrink-0"
60+
onError={(e) => {
61+
e.currentTarget.src = "/images/feed-image.png";
62+
}}
63+
/>
64+
<Badge variant="secondary" className="text-xs truncate">
65+
{feedName}
66+
</Badge>
67+
<Badge variant="outline" className="text-xs flex-shrink-0">
68+
#{feedId}
69+
</Badge>
70+
</div>
71+
</CardContent>
72+
</Card>
73+
);
74+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { useState, useMemo } from "react";
2+
import { ArrowLeft, ArrowRight } from "lucide-react";
3+
import { Button } from "@/components/ui/button";
4+
import { useRssFeed } from "@/hooks/use-rss-feed";
5+
import { FeedCard } from "./FeedCard";
6+
7+
interface RecentContentProps {
8+
feedId: string;
9+
feedName: string;
10+
feedImage?: string;
11+
}
12+
13+
export function RecentContent({
14+
feedId,
15+
feedName,
16+
feedImage,
17+
}: RecentContentProps) {
18+
const [currentPage, setCurrentPage] = useState(0);
19+
const { hasRssFeed, rssData, isLoading, isError } = useRssFeed(feedId);
20+
21+
// Get recent items (latest 9 items, 3 pages of 3 items each)
22+
const recentItems = useMemo(() => {
23+
if (!rssData || rssData.length === 0) return [];
24+
25+
// Sort by date (most recent first) and take first 9 items
26+
const sortedItems = [...rssData]
27+
.sort(
28+
(a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime(),
29+
)
30+
.slice(0, 9);
31+
32+
// Group into pages of 3
33+
const pages = [];
34+
for (let i = 0; i < sortedItems.length; i += 3) {
35+
pages.push(sortedItems.slice(i, i + 3));
36+
}
37+
38+
return pages;
39+
}, [rssData]);
40+
41+
const maxPages = recentItems.length;
42+
43+
const handlePrevious = () => {
44+
setCurrentPage((prev) => (prev > 0 ? prev - 1 : maxPages - 1));
45+
};
46+
47+
const handleNext = () => {
48+
setCurrentPage((prev) => (prev < maxPages - 1 ? prev + 1 : 0));
49+
};
50+
51+
// Don't render if no RSS feed or no data
52+
if (!hasRssFeed || isLoading || isError || recentItems.length === 0) {
53+
return null;
54+
}
55+
56+
const currentItems = recentItems[currentPage] || [];
57+
58+
return (
59+
<div className="space-y-4">
60+
{/* Header with Navigation - Responsive */}
61+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
62+
<h3 className="leading-8 text-xl sm:text-[24px] font-semibold">
63+
Recent Content
64+
</h3>
65+
{maxPages > 1 && (
66+
<div className="flex items-center gap-1">
67+
<Button
68+
variant="outline"
69+
size="icon"
70+
onClick={handlePrevious}
71+
className="h-8 w-8 p-3"
72+
aria-label="Previous page"
73+
>
74+
<ArrowLeft className="h-4 w-4" />
75+
</Button>
76+
<Button
77+
variant="outline"
78+
size="icon"
79+
onClick={handleNext}
80+
className="h-8 w-8 p-3"
81+
aria-label="Next page"
82+
>
83+
<ArrowRight className="h-4 w-4" />
84+
</Button>
85+
</div>
86+
)}
87+
</div>
88+
89+
{/* Content Cards - Responsive Grid */}
90+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
91+
{currentItems.map((item, index) => (
92+
<FeedCard
93+
key={item.guid || `${currentPage}-${index}`}
94+
item={item}
95+
feedName={feedName}
96+
feedId={feedId}
97+
feedImage={feedImage}
98+
/>
99+
))}
100+
</div>
101+
</div>
102+
);
103+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Badge } from "@/components/ui/badge";
2+
import { Button } from "@/components/ui/button";
3+
import { X } from "lucide-react";
4+
5+
interface FilterBadge {
6+
type: string;
7+
value: string;
8+
label: string;
9+
}
10+
11+
interface FilterBadgesProps {
12+
activeFilters: FilterBadge[];
13+
onRemoveFilter: (filterType: string) => void;
14+
onClearAll: () => void;
15+
}
16+
17+
export function FilterBadges({
18+
activeFilters,
19+
onRemoveFilter,
20+
onClearAll,
21+
}: FilterBadgesProps) {
22+
if (activeFilters.length === 0) return null;
23+
24+
return (
25+
<div className="flex flex-wrap gap-2 mb-4">
26+
{activeFilters.map((filter) => (
27+
<Badge
28+
key={filter.type}
29+
variant="secondary"
30+
className="inline-flex items-center gap-1 px-3 py-1"
31+
>
32+
<span>{filter.label}</span>
33+
<button
34+
onClick={() => onRemoveFilter(filter.type)}
35+
className="hover:bg-muted rounded-full p-0.5 ml-1"
36+
>
37+
<X className="h-3 w-3" />
38+
</button>
39+
</Badge>
40+
))}
41+
{activeFilters.length > 1 && (
42+
<Button
43+
variant="ghost"
44+
size="sm"
45+
onClick={onClearAll}
46+
className="h-auto px-3 py-1 text-xs"
47+
>
48+
Clear all
49+
</Button>
50+
)}
51+
</div>
52+
);
53+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Button } from "@/components/ui/button";
2+
import { Card, CardContent } from "@/components/ui/card";
3+
import {
4+
Select,
5+
SelectContent,
6+
SelectItem,
7+
SelectTrigger,
8+
SelectValue,
9+
} from "@/components/ui/select";
10+
11+
interface FilterControlsProps {
12+
showFilters: boolean;
13+
sortBy: "recent" | "oldest";
14+
platformFilter: string;
15+
categoryFilter: string;
16+
availableCategories: string[];
17+
onSortChange: (value: "recent" | "oldest") => void;
18+
onPlatformChange: (value: string) => void;
19+
onCategoryChange: (value: string) => void;
20+
onApplyFilters: () => void;
21+
onResetFilters: () => void;
22+
}
23+
24+
export function FilterControls({
25+
showFilters,
26+
sortBy,
27+
platformFilter,
28+
categoryFilter,
29+
availableCategories,
30+
onSortChange,
31+
onPlatformChange,
32+
onCategoryChange,
33+
onApplyFilters,
34+
onResetFilters,
35+
}: FilterControlsProps) {
36+
if (!showFilters) return null;
37+
38+
return (
39+
<Card className="mb-6">
40+
<CardContent className="p-4">
41+
<div className="flex md:flex-row flex-col w-full justify-between items-center gap-6">
42+
<div className="w-full">
43+
<p className="text-sm font-medium mb-1">Sort By</p>
44+
<Select value={sortBy} onValueChange={onSortChange}>
45+
<SelectTrigger className="bg-input text-foreground">
46+
<SelectValue placeholder="Most Recent" />
47+
</SelectTrigger>
48+
<SelectContent>
49+
<SelectItem value="recent">Most Recent</SelectItem>
50+
<SelectItem value="oldest">Oldest First</SelectItem>
51+
</SelectContent>
52+
</Select>
53+
</div>
54+
55+
<div className="w-full">
56+
<p className="text-sm font-medium mb-1">Platform</p>
57+
<Select value={platformFilter} onValueChange={onPlatformChange}>
58+
<SelectTrigger className="bg-input text-foreground">
59+
<SelectValue placeholder="All Platforms" />
60+
</SelectTrigger>
61+
<SelectContent>
62+
<SelectItem value="all">All Platforms</SelectItem>
63+
<SelectItem value="twitter">Twitter</SelectItem>
64+
<SelectItem value="youtube">YouTube</SelectItem>
65+
<SelectItem value="github">GitHub</SelectItem>
66+
<SelectItem value="reddit">Reddit</SelectItem>
67+
<SelectItem value="other">Other</SelectItem>
68+
</SelectContent>
69+
</Select>
70+
</div>
71+
72+
<div className="w-full">
73+
<p className="text-sm font-medium mb-1">Category</p>
74+
<Select value={categoryFilter} onValueChange={onCategoryChange}>
75+
<SelectTrigger className="bg-input text-foreground">
76+
<SelectValue placeholder="All Categories" />
77+
</SelectTrigger>
78+
<SelectContent>
79+
<SelectItem value="all">All Categories</SelectItem>
80+
{availableCategories.map((category) => (
81+
<SelectItem key={category} value={category}>
82+
{category}
83+
</SelectItem>
84+
))}
85+
</SelectContent>
86+
</Select>
87+
</div>
88+
</div>
89+
90+
<div className="flex justify-end mt-4 gap-2">
91+
<Button variant="outline" onClick={onResetFilters}>
92+
Reset
93+
</Button>
94+
<Button onClick={onApplyFilters}>Apply Filters</Button>
95+
</div>
96+
</CardContent>
97+
</Card>
98+
);
99+
}

0 commit comments

Comments
 (0)