SoftTraderFrontend/app/interest-details/page.tsx
2026-03-20 11:10:13 -04:00

267 lines
10 KiB
TypeScript

'use client'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { ArrowLeft } from 'lucide-react'
import { Suspense, useCallback, useEffect, useRef, useState } from 'react'
import { SentimentalityBar } from '@/components/widgets/SentimentalityBar'
import { getNewsFeed, refreshNewsFeed, formatPublishedDate, type NewsItem } from '@/lib/newsApi'
// ── Pull-to-refresh constants ──────────────────────────────────────────────
const PULL_THRESHOLD = 72 // px of pull before triggering refresh
const PULL_MAX = 100 // px cap so it doesn't over-stretch
function PullIndicator({ pullY, refreshing, toast }: {
pullY: number
refreshing: boolean
toast: 'refreshed' | 'error' | null
}) {
const progress = Math.min(pullY / PULL_THRESHOLD, 1)
const circumference = 2 * Math.PI * 10
return (
<div
className="absolute left-0 right-0 flex flex-col items-center pointer-events-none z-10 bg-background"
style={{
top: refreshing ? 0 : -56,
transform: refreshing ? 'translateY(0)' : `translateY(${pullY}px)`,
transition: refreshing || pullY > 0 ? 'none' : 'transform 240ms ease, top 240ms ease',
height: 56,
}}
>
<div className="mt-3 w-10 h-10 flex items-center justify-center">
{refreshing ? (
// Spinning ring
<svg className="animate-spin" width="28" height="28" viewBox="0 0 28 28" fill="none">
<circle cx="14" cy="14" r="10" stroke="currentColor" strokeWidth="2.5" strokeOpacity="0.2" />
<path
d="M14 4 A10 10 0 0 1 24 14"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"
/>
</svg>
) : (
// Progress arc that fills as user pulls
<svg
width="28" height="28" viewBox="0 0 28 28" fill="none"
style={{ transform: `rotate(${progress * 360}deg)`, transition: 'none' }}
>
<circle cx="14" cy="14" r="10" stroke="currentColor" strokeWidth="2.5" strokeOpacity="0.15" />
<circle
cx="14" cy="14" r="10"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - progress)}
style={{ transform: 'rotate(-90deg)', transformOrigin: '14px 14px' }}
/>
</svg>
)}
</div>
{/* Toast message */}
{toast && (
<div
className={`mt-1 px-3 py-1 text-xs font-medium rounded-full ${
toast === 'refreshed'
? 'bg-positive/15 text-positive'
: 'bg-negative/15 text-negative'
}`}
>
{toast === 'refreshed' ? 'Refreshed' : 'Unable to refresh headlines'}
</div>
)}
</div>
)
}
function InterestDetailsContent() {
const searchParams = useSearchParams()
const topic = searchParams.get('topic') || 'Topic'
const [items, setItems] = useState<NewsItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [limit, setLimit] = useState<number>(10)
// Pull-to-refresh state
const [pullY, setPullY] = useState(0)
const [refreshing, setRefreshing] = useState(false)
const [toast, setToast] = useState<'refreshed' | 'error' | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const touchStartY = useRef(0)
const isPulling = useRef(false)
const isRefreshing = useRef(false) // guards against duplicate calls
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// ── Fetch headlines ──────────────────────────────────────────────────────
const fetchHeadlines = useCallback(() => {
setLoading(true)
setError(null)
getNewsFeed(topic, 'frontend', limit)
.then(setItems)
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [topic, limit])
useEffect(() => { fetchHeadlines() }, [fetchHeadlines])
// ── Clear toast on unmount ───────────────────────────────────────────────
useEffect(() => () => { if (toastTimer.current) clearTimeout(toastTimer.current) }, [])
// ── Show toast then hide after 2.5s ─────────────────────────────────────
const showToast = (type: 'refreshed' | 'error') => {
setToast(type)
if (toastTimer.current) clearTimeout(toastTimer.current)
toastTimer.current = setTimeout(() => setToast(null), 2500)
}
// ── Trigger refresh ──────────────────────────────────────────────────────
const triggerRefresh = useCallback(async () => {
if (isRefreshing.current) return
isRefreshing.current = true
setRefreshing(true)
setPullY(0)
try {
const ok = await refreshNewsFeed('frontend')
if (ok) {
showToast('refreshed')
fetchHeadlines()
} else {
showToast('error')
}
} catch {
showToast('error')
} finally {
setRefreshing(false)
isRefreshing.current = false
}
}, [fetchHeadlines])
// ── Touch handlers ───────────────────────────────────────────────────────
const handleTouchStart = useCallback((e: React.TouchEvent) => {
if (isRefreshing.current) return
const el = scrollRef.current
if (el && el.scrollTop === 0) {
touchStartY.current = e.touches[0].clientY
isPulling.current = true
}
}, [])
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (!isPulling.current || isRefreshing.current) return
const dy = e.touches[0].clientY - touchStartY.current
if (dy <= 0) { setPullY(0); return }
// Rubber-band decay
const capped = Math.min(dy * 0.45, PULL_MAX)
setPullY(capped)
}, [])
const handleTouchEnd = useCallback(() => {
if (!isPulling.current) return
isPulling.current = false
if (pullY >= PULL_THRESHOLD) {
triggerRefresh()
} else {
setPullY(0)
}
}, [pullY, triggerRefresh])
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="bg-background border-b border-border px-6 py-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<Link href="/interests" className="p-2 hover:bg-secondary transition-colors">
<ArrowLeft size={20} />
</Link>
<h1 className="text-2xl font-bold">Top headlines for {topic}</h1>
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-foreground font-medium">Number of headlines to show:</label>
<select
value={limit}
onChange={(e) => setLimit(Number(e.target.value))}
disabled={loading}
className="border border-border bg-input text-foreground text-sm px-3 py-1.5 cursor-pointer disabled:opacity-50"
>
<option value={5}>5</option>
<option value={10}>10</option>
<option value={25}>25</option>
</select>
</div>
</div>
{/* Scroll area with pull-to-refresh */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto relative bg-background"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<PullIndicator pullY={pullY} refreshing={refreshing} toast={toast} />
{/* Push content down while pulling or refreshing */}
<div style={{
paddingTop: refreshing ? 56 : pullY,
transition: isPulling.current ? 'none' : 'padding-top 240ms ease',
}}>
{loading && (
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
Loading headlines...
</div>
)}
{error && (
<div className="flex items-center justify-center h-32 text-sm text-negative">
{error}
</div>
)}
{!loading && !error && items.length === 0 && (
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
No headlines found for &quot;{topic}&quot;
</div>
)}
{!loading && !error && items.length > 0 && (
<div className="divide-y divide-border">
{items.map((item, index) => (
<a
key={index}
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="bg-card border-b border-border p-6 hover:bg-secondary transition-colors cursor-pointer space-y-4 block"
>
<div className="space-y-2">
<div className="text-sm font-medium">
<span className="text-primary font-semibold">{item.publisher}:</span> {item.title}
</div>
</div>
<div className="text-xs text-muted-foreground">
{formatPublishedDate(item.publishedAt)}
</div>
<div className="pt-2 border-t border-border">
<SentimentalityBar sentiment={Math.round(item.score)} />
</div>
</a>
))}
</div>
)}
</div>
</div>
</div>
)
}
export default function InterestDetails() {
return (
<Suspense fallback={
<div className="h-full flex items-center justify-center bg-background">
<div className="text-muted-foreground">Loading...</div>
</div>
}>
<InterestDetailsContent />
</Suspense>
)
}