Skip to content

Commit e6bfd88

Browse files
committed
Feed updates
1 parent e6eae09 commit e6bfd88

9 files changed

Lines changed: 903 additions & 251 deletions

File tree

src/components/FeedColumn.tsx

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
import * as React from 'react'
2+
import { useMemo } from 'react'
3+
import { formatDistanceToNow } from 'date-fns'
4+
import { Link } from '@tanstack/react-router'
5+
import { twMerge } from 'tailwind-merge'
6+
import { useInfiniteQuery } from '@tanstack/react-query'
7+
import { listFeedEntries } from '~/utils/feed.functions'
8+
import { FeedEntry } from '~/components/FeedEntry'
9+
import { FaSpinner } from 'react-icons/fa'
10+
import { LuExpand } from 'react-icons/lu'
11+
import { useIntersectionObserver } from '~/hooks/useIntersectionObserver'
12+
import type { FeedFilters } from '~/queries/feed'
13+
14+
interface FeedColumnProps {
15+
source: string
16+
filters: Omit<FeedFilters, 'sources'>
17+
pageSize: number
18+
expandedIds?: string[]
19+
onExpandedChange?: (expandedIds: string[]) => void
20+
onViewModeChange?: (viewMode: 'table' | 'timeline' | 'columns') => void
21+
onFiltersChange?: (filters: { sources?: string[] }) => void
22+
adminActions?: {
23+
onEdit?: (entry: FeedEntry) => void
24+
onToggleVisibility?: (entry: FeedEntry, isVisible: boolean) => void
25+
onToggleFeatured?: (entry: FeedEntry, featured: boolean) => void
26+
onDelete?: (entry: FeedEntry) => void
27+
}
28+
}
29+
30+
export function FeedColumn({
31+
source,
32+
filters,
33+
pageSize,
34+
expandedIds,
35+
onExpandedChange,
36+
onViewModeChange,
37+
onFiltersChange,
38+
adminActions,
39+
}: FeedColumnProps) {
40+
const infiniteQuery = useInfiniteQuery({
41+
queryKey: ['feed', 'infinite', source, filters],
42+
queryFn: ({ pageParam = 0 }) =>
43+
listFeedEntries({
44+
data: {
45+
pagination: {
46+
limit: pageSize,
47+
page: pageParam,
48+
},
49+
filters: {
50+
...filters,
51+
sources: [source],
52+
},
53+
},
54+
}),
55+
getNextPageParam: (
56+
lastPage: { isDone: boolean },
57+
allPages: Array<{ isDone: boolean }>,
58+
) => {
59+
if (lastPage.isDone) {
60+
return undefined
61+
}
62+
return allPages.length
63+
},
64+
initialPageParam: 0,
65+
})
66+
67+
// Derive accumulated entries from all pages
68+
const allEntries = useMemo(() => {
69+
const entries: FeedEntry[] = []
70+
const seenIds = new Set<string>()
71+
72+
for (const page of infiniteQuery.data?.pages ?? []) {
73+
if (page.page) {
74+
for (const entry of page.page) {
75+
if (!seenIds.has(entry._id)) {
76+
seenIds.add(entry._id)
77+
entries.push(entry)
78+
}
79+
}
80+
}
81+
}
82+
83+
return entries
84+
}, [infiniteQuery.data?.pages])
85+
86+
const isLoading = infiniteQuery.isLoading && !infiniteQuery.data
87+
const isLoadingMore = infiniteQuery.isFetchingNextPage
88+
const hasError = infiniteQuery.isError
89+
const hasNoData = !isLoading && allEntries.length === 0
90+
const isBackgroundFetching = infiniteQuery.isFetching && !isLoading
91+
const hasNextPage = infiniteQuery.hasNextPage
92+
const totalCount = infiniteQuery.data?.pages[0]?.counts.total ?? 0
93+
94+
const { ref: loadMoreRef, isIntersecting } = useIntersectionObserver({
95+
rootMargin: '200px',
96+
threshold: 0,
97+
triggerOnce: false,
98+
})
99+
100+
// Load more when intersection observer triggers
101+
React.useEffect(() => {
102+
if (
103+
isIntersecting &&
104+
hasNextPage &&
105+
!infiniteQuery.isFetchingNextPage &&
106+
allEntries.length > 0
107+
) {
108+
infiniteQuery.fetchNextPage()
109+
}
110+
}, [
111+
isIntersecting,
112+
hasNextPage,
113+
infiniteQuery.isFetchingNextPage,
114+
allEntries.length,
115+
infiniteQuery,
116+
])
117+
118+
const handleExpand = () => {
119+
// Determine target view mode - check localStorage for preference, default to table
120+
let targetViewMode: 'table' | 'timeline' = 'table'
121+
if (typeof window !== 'undefined') {
122+
const savedViewMode = localStorage.getItem('feedViewMode')
123+
if (savedViewMode === 'table' || savedViewMode === 'timeline') {
124+
targetViewMode = savedViewMode
125+
}
126+
}
127+
onViewModeChange?.(targetViewMode)
128+
onFiltersChange?.({ sources: [source] })
129+
}
130+
131+
return (
132+
<div className="flex-shrink-0 w-[280px] flex flex-col h-full max-h-full overflow-hidden">
133+
{/* Column Header */}
134+
<div className="flex-shrink-0 sticky top-0 z-10 bg-white dark:bg-black border-b-2 border-gray-300 dark:border-gray-700 pb-2 mb-3">
135+
{/* Background Fetching Indicator */}
136+
{isBackgroundFetching && (
137+
<div className="absolute top-0 left-0 right-0 h-0.5 bg-blue-500 dark:bg-blue-400 animate-pulse" />
138+
)}
139+
<div className="flex items-center justify-between gap-2 mb-1">
140+
<div className="flex items-center gap-1.5">
141+
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 uppercase tracking-wide">
142+
{source}
143+
</h3>
144+
{isBackgroundFetching && (
145+
<FaSpinner className="w-3 h-3 text-gray-400 dark:text-gray-500 animate-spin" />
146+
)}
147+
</div>
148+
<button
149+
onClick={handleExpand}
150+
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors whitespace-nowrap"
151+
title={`Expand ${source} column`}
152+
>
153+
<LuExpand className="w-3 h-3" />
154+
<span className="hidden sm:inline">Expand</span>
155+
</button>
156+
</div>
157+
<div className="text-xs text-gray-500 dark:text-gray-400">
158+
{totalCount} {totalCount === 1 ? 'entry' : 'entries'}
159+
{allEntries.length > 0 && allEntries.length < totalCount && (
160+
<span className="ml-1">
161+
({allEntries.length} loaded)
162+
</span>
163+
)}
164+
</div>
165+
</div>
166+
167+
{/* Column Content */}
168+
<div className="space-y-2.5 flex-1 overflow-y-auto min-h-0">
169+
{isLoading && (
170+
<div className="flex items-center justify-center py-8">
171+
<FaSpinner className="animate-spin text-xl text-gray-500" />
172+
</div>
173+
)}
174+
175+
{hasError && (
176+
<div className="text-center py-8">
177+
<p className="text-xs text-red-500 dark:text-red-400">
178+
Error loading {source} entries
179+
</p>
180+
</div>
181+
)}
182+
183+
{hasNoData && !isLoading && (
184+
<div className="text-center py-8">
185+
<p className="text-xs text-gray-500 dark:text-gray-400">
186+
No {source} entries found
187+
</p>
188+
</div>
189+
)}
190+
191+
{allEntries.map((entry) => (
192+
<FeedEntryColumn
193+
key={entry._id}
194+
entry={entry}
195+
expanded={expandedIds?.includes(entry._id) ?? false}
196+
onExpandedChange={(expanded) => {
197+
if (!onExpandedChange) return
198+
const current = expandedIds ?? []
199+
if (expanded) {
200+
onExpandedChange([...current, entry._id])
201+
} else {
202+
onExpandedChange(current.filter((id) => id !== entry._id))
203+
}
204+
}}
205+
adminActions={adminActions}
206+
/>
207+
))}
208+
209+
{/* Load More Sentinel */}
210+
{hasNextPage && allEntries.length > 0 && (
211+
<div ref={loadMoreRef} className="py-4 flex items-center justify-center">
212+
{isLoadingMore && (
213+
<FaSpinner className="animate-spin text-sm text-gray-500" />
214+
)}
215+
</div>
216+
)}
217+
</div>
218+
</div>
219+
)
220+
}
221+
222+
interface FeedEntryColumnProps {
223+
entry: FeedEntry
224+
expanded?: boolean
225+
onExpandedChange?: (expanded: boolean) => void
226+
adminActions?: {
227+
onEdit?: (entry: FeedEntry) => void
228+
onToggleVisibility?: (entry: FeedEntry, isVisible: boolean) => void
229+
onToggleFeatured?: (entry: FeedEntry, featured: boolean) => void
230+
onDelete?: (entry: FeedEntry) => void
231+
}
232+
}
233+
234+
function FeedEntryColumn({
235+
entry,
236+
expanded = false,
237+
onExpandedChange,
238+
adminActions,
239+
}: FeedEntryColumnProps) {
240+
const setExpanded = (value: boolean) => {
241+
onExpandedChange?.(value)
242+
}
243+
244+
// Determine entry type badge
245+
const getTypeBadge = () => {
246+
const isPrerelease = entry.tags.includes('release:prerelease')
247+
248+
const badgeConfigs: Record<string, { label: string; className: string }> =
249+
{
250+
release: {
251+
label: 'Release',
252+
className:
253+
'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200',
254+
},
255+
prerelease: {
256+
label: 'Prerelease',
257+
className:
258+
'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200',
259+
},
260+
blog: {
261+
label: 'Blog',
262+
className:
263+
'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
264+
},
265+
announcement: {
266+
label: 'Announcement',
267+
className:
268+
'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200',
269+
},
270+
partner: {
271+
label: 'Partner',
272+
className:
273+
'bg-pink-100 dark:bg-pink-900 text-pink-800 dark:text-pink-200',
274+
},
275+
}
276+
277+
const category = entry.category
278+
const key =
279+
category === 'release' && isPrerelease ? 'prerelease' : category
280+
281+
return (
282+
badgeConfigs[key] || {
283+
label: entry.source,
284+
className:
285+
'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200',
286+
}
287+
)
288+
}
289+
290+
const badge = getTypeBadge()
291+
292+
return (
293+
<article
294+
className={twMerge(
295+
'bg-white dark:bg-black border border-gray-200 dark:border-gray-800 rounded-md p-2.5 transition-all',
296+
'hover:shadow-sm hover:border-gray-300 dark:hover:border-gray-700',
297+
entry.featured &&
298+
'bg-yellow-50 dark:bg-yellow-900/10 border-yellow-200 dark:border-yellow-800',
299+
)}
300+
>
301+
{/* Badge and Date */}
302+
<div className="flex items-center gap-1.5 flex-wrap mb-1.5">
303+
<span
304+
className={twMerge(
305+
'px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide',
306+
badge.className,
307+
)}
308+
>
309+
{badge.label}
310+
</span>
311+
{entry.featured && (
312+
<span className="px-1 py-0.5 text-[10px]"></span>
313+
)}
314+
<time
315+
dateTime={new Date(entry.publishedAt).toISOString()}
316+
className="text-[10px] text-gray-500 dark:text-gray-400 ml-auto"
317+
>
318+
{formatDistanceToNow(new Date(entry.publishedAt), {
319+
addSuffix: true,
320+
})}
321+
</time>
322+
</div>
323+
324+
{/* Title */}
325+
<Link
326+
to="/feed/$id"
327+
params={{ id: entry._id }}
328+
search={{} as any}
329+
className="text-xs font-semibold text-gray-900 dark:text-gray-100 mb-1 hover:text-blue-600 dark:hover:text-blue-400 transition-colors line-clamp-2 block leading-tight"
330+
>
331+
{entry.title}
332+
</Link>
333+
334+
{/* Excerpt */}
335+
{entry.excerpt && !expanded && (
336+
<p className="text-[11px] text-gray-600 dark:text-gray-400 line-clamp-2 mb-1.5 leading-relaxed">
337+
{entry.excerpt}
338+
</p>
339+
)}
340+
341+
{/* Expanded Content */}
342+
{expanded && (
343+
<div className="text-[11px] text-gray-700 dark:text-gray-300 mb-1.5">
344+
{entry.excerpt && (
345+
<p className="mb-1.5 leading-relaxed">{entry.excerpt}</p>
346+
)}
347+
</div>
348+
)}
349+
350+
{/* Footer */}
351+
<div className="flex items-center justify-between pt-1.5 border-t border-gray-100 dark:border-gray-800">
352+
<button
353+
onClick={() => setExpanded(!expanded)}
354+
className="text-[10px] text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 font-medium"
355+
>
356+
{expanded ? 'Show less' : 'Read more'}
357+
</button>
358+
</div>
359+
</article>
360+
)
361+
}
362+

0 commit comments

Comments
 (0)