267 lines
10 KiB
TypeScript
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 "{topic}"
|
|
</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>
|
|
)
|
|
}
|