Skip to content

Commit c90a45f

Browse files
committed
Fix conflicts
1 parent 97c0f7d commit c90a45f

34 files changed

Lines changed: 3152 additions & 672 deletions
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import * as React from 'react'
2+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
3+
import { useLocation, Link } from '@tanstack/react-router'
4+
import {
5+
X,
6+
Info,
7+
AlertTriangle,
8+
CheckCircle,
9+
Gift,
10+
ExternalLink,
11+
ArrowRight,
12+
} from 'lucide-react'
13+
import {
14+
getActiveBanners,
15+
getDismissedBannerIds,
16+
dismissBanner,
17+
type ActiveBanner,
18+
} from '~/utils/banner.functions'
19+
20+
const DISMISSED_BANNERS_KEY = 'tanstack_dismissed_banners'
21+
22+
// Get dismissed banner IDs from localStorage (for anonymous users)
23+
function getLocalDismissedBanners(): string[] {
24+
if (typeof window === 'undefined') return []
25+
try {
26+
const stored = localStorage.getItem(DISMISSED_BANNERS_KEY)
27+
return stored ? JSON.parse(stored) : []
28+
} catch {
29+
return []
30+
}
31+
}
32+
33+
// Save dismissed banner ID to localStorage
34+
function saveLocalDismissedBanner(bannerId: string) {
35+
if (typeof window === 'undefined') return
36+
try {
37+
const existing = getLocalDismissedBanners()
38+
if (!existing.includes(bannerId)) {
39+
localStorage.setItem(
40+
DISMISSED_BANNERS_KEY,
41+
JSON.stringify([...existing, bannerId]),
42+
)
43+
}
44+
} catch {
45+
// Ignore localStorage errors
46+
}
47+
}
48+
49+
const BANNER_STYLES = {
50+
info: {
51+
icon: Info,
52+
bgClass:
53+
'bg-blue-100/90 dark:bg-blue-950/90 backdrop-blur-md border-blue-200 dark:border-blue-800',
54+
textClass: 'text-blue-900 dark:text-blue-100',
55+
iconClass: 'text-blue-600 dark:text-blue-400',
56+
linkClass:
57+
'text-blue-700 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-200',
58+
},
59+
warning: {
60+
icon: AlertTriangle,
61+
bgClass:
62+
'bg-amber-100/90 dark:bg-amber-950/90 backdrop-blur-md border-amber-200 dark:border-amber-800',
63+
textClass: 'text-amber-900 dark:text-amber-100',
64+
iconClass: 'text-amber-600 dark:text-amber-400',
65+
linkClass:
66+
'text-amber-700 dark:text-amber-300 hover:text-amber-800 dark:hover:text-amber-200',
67+
},
68+
success: {
69+
icon: CheckCircle,
70+
bgClass:
71+
'bg-green-100/90 dark:bg-green-950/90 backdrop-blur-md border-green-200 dark:border-green-800',
72+
textClass: 'text-green-900 dark:text-green-100',
73+
iconClass: 'text-green-600 dark:text-green-400',
74+
linkClass:
75+
'text-green-700 dark:text-green-300 hover:text-green-800 dark:hover:text-green-200',
76+
},
77+
promo: {
78+
icon: Gift,
79+
bgClass:
80+
'bg-purple-100/90 dark:bg-purple-950/90 backdrop-blur-md border-purple-200 dark:border-purple-800',
81+
textClass: 'text-purple-900 dark:text-purple-100',
82+
iconClass: 'text-purple-600 dark:text-purple-400',
83+
linkClass:
84+
'text-purple-700 dark:text-purple-300 hover:text-purple-800 dark:hover:text-purple-200',
85+
},
86+
} as const
87+
88+
interface BannerItemProps {
89+
banner: ActiveBanner
90+
onDismiss: (bannerId: string) => void
91+
}
92+
93+
function BannerItem({ banner, onDismiss }: BannerItemProps) {
94+
const style = BANNER_STYLES[banner.style] || BANNER_STYLES.info
95+
const Icon = style.icon
96+
const isExternalLink =
97+
banner.linkUrl?.startsWith('http://') ||
98+
banner.linkUrl?.startsWith('https://')
99+
const isInternalLink = banner.linkUrl?.startsWith('/')
100+
101+
const linkContent = banner.linkUrl && (
102+
<span className="inline-flex items-center gap-1.5 font-medium underline underline-offset-2 hover:no-underline">
103+
{banner.linkText || 'Learn More'}
104+
{isExternalLink ? (
105+
<ExternalLink className="w-3 h-3" />
106+
) : (
107+
<ArrowRight className="w-3 h-3" />
108+
)}
109+
</span>
110+
)
111+
112+
return (
113+
<div
114+
className={`border-b ${style.bgClass} ${style.textClass}`}
115+
role="alert"
116+
>
117+
<div className="max-w-7xl mx-auto px-4 py-3">
118+
<div className="flex items-start gap-3">
119+
<Icon className={`w-5 h-5 mt-0.5 flex-shrink-0 ${style.iconClass}`} />
120+
<div className="flex-1 min-w-0">
121+
<div className="font-medium">{banner.title}</div>
122+
{banner.content && (
123+
<div className="text-sm opacity-90 mt-0.5 line-clamp-2">
124+
{banner.content}
125+
</div>
126+
)}
127+
{banner.linkUrl && (
128+
<div className={`text-sm mt-1.5 ${style.linkClass}`}>
129+
{isExternalLink ? (
130+
<a
131+
href={banner.linkUrl}
132+
target="_blank"
133+
rel="noopener noreferrer"
134+
>
135+
{linkContent}
136+
</a>
137+
) : isInternalLink ? (
138+
<Link to={banner.linkUrl}>{linkContent}</Link>
139+
) : (
140+
<a href={banner.linkUrl}>{linkContent}</a>
141+
)}
142+
</div>
143+
)}
144+
</div>
145+
<button
146+
type="button"
147+
onClick={(e) => {
148+
e.preventDefault()
149+
e.stopPropagation()
150+
onDismiss(banner.id)
151+
}}
152+
className="flex-shrink-0 p-1 rounded-md opacity-60 hover:opacity-100 hover:bg-black/5 dark:hover:bg-white/10 transition-all"
153+
aria-label="Dismiss"
154+
>
155+
<X className="w-4 h-4" />
156+
</button>
157+
</div>
158+
</div>
159+
</div>
160+
)
161+
}
162+
163+
export function AnnouncementBanner() {
164+
const location = useLocation()
165+
const queryClient = useQueryClient()
166+
const [localDismissed, setLocalDismissed] = React.useState<string[]>([])
167+
168+
// Load local dismissed banners on mount (client-side only)
169+
React.useEffect(() => {
170+
setLocalDismissed(getLocalDismissedBanners())
171+
}, [])
172+
173+
// Fetch active banners for current path
174+
const bannersQuery = useQuery({
175+
queryKey: ['activeBanners', location.pathname],
176+
queryFn: () => getActiveBanners({ data: { pathname: location.pathname } }),
177+
staleTime: 1000 * 60 * 5, // 5 minutes
178+
})
179+
180+
// Fetch dismissed banner IDs for logged-in users
181+
const dismissedQuery = useQuery({
182+
queryKey: ['dismissedBanners'],
183+
queryFn: () => getDismissedBannerIds(),
184+
staleTime: 1000 * 60 * 5, // 5 minutes
185+
})
186+
187+
// Mutation to dismiss a banner
188+
const dismissMutation = useMutation({
189+
mutationFn: (bannerId: string) => dismissBanner({ data: { bannerId } }),
190+
onSuccess: (result, bannerId) => {
191+
if (result.success) {
192+
// Server dismissal succeeded (logged-in user)
193+
queryClient.invalidateQueries({ queryKey: ['dismissedBanners'] })
194+
}
195+
// Always save to localStorage as fallback
196+
saveLocalDismissedBanner(bannerId)
197+
setLocalDismissed((prev) => [...prev, bannerId])
198+
},
199+
onError: (_, bannerId) => {
200+
// If server fails, still save locally
201+
saveLocalDismissedBanner(bannerId)
202+
setLocalDismissed((prev) => [...prev, bannerId])
203+
},
204+
})
205+
206+
const handleDismiss = (bannerId: string) => {
207+
dismissMutation.mutate(bannerId)
208+
}
209+
210+
// Combine server and local dismissed IDs
211+
const allDismissed = React.useMemo(() => {
212+
const serverDismissed = dismissedQuery.data || []
213+
return new Set([...serverDismissed, ...localDismissed])
214+
}, [dismissedQuery.data, localDismissed])
215+
216+
// Filter out dismissed banners
217+
const visibleBanners = React.useMemo(() => {
218+
if (!bannersQuery.data) return []
219+
return bannersQuery.data.filter((banner) => !allDismissed.has(banner.id))
220+
}, [bannersQuery.data, allDismissed])
221+
222+
if (visibleBanners.length === 0) {
223+
return null
224+
}
225+
226+
return (
227+
<div className="w-full">
228+
{visibleBanners.map((banner) => (
229+
<BannerItem
230+
key={banner.id}
231+
banner={banner}
232+
onDismiss={handleDismiss}
233+
/>
234+
))}
235+
</div>
236+
)
237+
}

src/components/FeedEntry.tsx

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,21 @@ import { Eye, EyeOff, SquarePen, Star, Trash } from 'lucide-react'
1010
export interface FeedEntry {
1111
_id: string
1212
id: string
13-
source: string
13+
entryType: 'release' | 'blog' | 'announcement'
1414
title: string
1515
content: string
16-
excerpt?: string
16+
excerpt?: string | null
1717
publishedAt: number
18+
createdAt: number
19+
updatedAt?: number
1820
metadata?: any
1921
libraryIds: string[]
2022
partnerIds?: string[]
2123
tags: string[]
22-
category: 'release' | 'announcement' | 'blog' | 'partner' | 'update' | 'other'
23-
isVisible: boolean
24+
showInFeed: boolean
2425
featured?: boolean
2526
autoSynced: boolean
27+
lastSyncedAt?: number
2628
}
2729

2830
interface FeedEntryProps {
@@ -93,14 +95,21 @@ export function FeedEntry({
9395
className:
9496
'bg-pink-100 dark:bg-pink-900 text-pink-800 dark:text-pink-200',
9597
},
98+
update: {
99+
label: 'Update',
100+
className:
101+
'bg-teal-100 dark:bg-teal-900 text-teal-800 dark:text-teal-200',
102+
},
96103
}
97104

98-
const category = entry.category
99-
const key = category === 'release' && isPrerelease ? 'prerelease' : category
105+
const key =
106+
entry.entryType === 'release' && isPrerelease
107+
? 'prerelease'
108+
: entry.entryType
100109

101110
return (
102111
badgeConfigs[key] || {
103-
label: entry.source,
112+
label: entry.entryType,
104113
className:
105114
'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200',
106115
}
@@ -151,10 +160,10 @@ export function FeedEntry({
151160
// Determine external link if available
152161
const getExternalLink = () => {
153162
if (entry.metadata) {
154-
if (entry.source === 'github' && entry.metadata.url) {
163+
if (entry.entryType === 'release' && entry.metadata.url) {
155164
return entry.metadata.url
156165
}
157-
if (entry.source === 'blog' && entry.metadata.url) {
166+
if (entry.entryType === 'blog' && entry.metadata.url) {
158167
return entry.metadata.url
159168
}
160169
}
@@ -276,7 +285,7 @@ export function FeedEntry({
276285
277286
</span>
278287
)}
279-
{!entry.isVisible && (
288+
{!entry.showInFeed && (
280289
<span className="px-1 py-0.5 rounded text-[10px] bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200">
281290
Hidden
282291
</span>
@@ -309,12 +318,12 @@ export function FeedEntry({
309318
{adminActions.onToggleVisibility && (
310319
<button
311320
onClick={() =>
312-
adminActions.onToggleVisibility!(entry, !entry.isVisible)
321+
adminActions.onToggleVisibility!(entry, !entry.showInFeed)
313322
}
314323
className="p-0.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors text-gray-600 dark:text-gray-400"
315-
title={entry.isVisible ? 'Hide' : 'Show'}
324+
title={entry.showInFeed ? 'Hide' : 'Show'}
316325
>
317-
{entry.isVisible ? (
326+
{entry.showInFeed ? (
318327
<Eye className="w-3 h-3" />
319328
) : (
320329
<EyeOff className="w-3 h-3" />
@@ -357,9 +366,6 @@ export function FeedEntry({
357366
<div className="pl-8">
358367
{/* Metadata Row */}
359368
<div className="flex items-center gap-4 mb-3 text-[11px] text-gray-600 dark:text-gray-400">
360-
{entry.source !== 'announcement' && (
361-
<span className="capitalize">{entry.source}</span>
362-
)}
363369
{entryLibraries.length > 0 && (
364370
<div className="flex items-center gap-2">
365371
<span>Libraries:</span>
@@ -422,7 +428,7 @@ export function FeedEntry({
422428
className="text-blue-600 dark:text-blue-400 hover:underline text-xs font-medium"
423429
onClick={(e) => e.stopPropagation()}
424430
>
425-
View on {entry.source === 'github' ? 'GitHub' : 'Blog'}
431+
View on {entry.entryType === 'release' ? 'GitHub' : 'Blog'}
426432
</a>
427433
</div>
428434
)}

0 commit comments

Comments
 (0)