'use client' import { useEffect, useRef, useState } from 'react' import { PortableTextRenderer } from '@/components/PortableTextRenderer' import { RoadmapItem } from '@/components/RoadmapItem' import { FeedbackForm } from '@/components/FeedbackForm' import type { ChangelogEntry, RoadmapItemData, Tab, VoteState } from '@/types/changelog' import { useAuth } from '@/context/AuthContext' import Link from 'next/link' const TABS: { id: Tab; label: string }[] = [ { id: 'whats-new', label: "What's New" }, { id: 'whats-next', label: "What's Next" }, { id: 'give-feedback', label: 'Give Feedback' }, ] const TAG_STYLES: Record = { feature: 'bg-amber-500/10 text-amber-400 border border-amber-500/25', fix: 'bg-green-500/10 text-green-400 border border-green-500/20', improvement: 'bg-blue-400/10 text-blue-400 border border-blue-400/20', } function formatDate(iso: string) { return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', }) } export function ChangelogTabs({ entries, roadmapItems, }: { entries: ChangelogEntry[] roadmapItems: RoadmapItemData[] }) { const [activeTab, setActiveTab] = useState('whats-new') const [voteState, setVoteState] = useState>( () => Object.fromEntries( roadmapItems.map((item) => [ item._id, { count: 0, voted: false, loading: false }, ]) ) ) const { user } = useAuth() const [showSignInPrompt, setShowSignInPrompt] = useState(false) const signInTimerRef = useRef | null>(null) const [votesFetched, setVotesFetched] = useState(false) // Clear sign-in prompt timer on unmount useEffect(() => () => { if (signInTimerRef.current) clearTimeout(signInTimerRef.current) }, []) // Stable key for roadmapItems identity — avoids unstable array reference in deps const roadmapIdKey = roadmapItems.map((i) => i._id).join(',') // Fetch vote state when the What's Next tab is first viewed useEffect(() => { if (activeTab !== 'whats-next' || votesFetched || !roadmapIdKey) return setVotesFetched(true) fetch(`/api/roadmap/votes?ids=${encodeURIComponent(roadmapIdKey)}`, { credentials: 'include', }) .then((r) => (r.ok ? r.json() : {})) .then((data: Record) => { setVoteState((prev) => { const next = { ...prev } for (const [id, v] of Object.entries(data)) { next[id] = { count: v.count, voted: v.voted, loading: false } } return next }) }) .catch(() => { // Spring Boot not yet available — silently keep count=0 }) }, [activeTab, votesFetched, roadmapIdKey]) // eslint-disable-line react-hooks/exhaustive-deps async function handleVote(id: string) { if (!user) { if (signInTimerRef.current) clearTimeout(signInTimerRef.current) setShowSignInPrompt(true) signInTimerRef.current = setTimeout(() => setShowSignInPrompt(false), 4000) return } const current = voteState[id] ?? { count: 0, voted: false, loading: false } // Optimistic update setVoteState((prev) => ({ ...prev, [id]: { count: current.voted ? current.count - 1 : current.count + 1, voted: !current.voted, loading: true, }, })) try { const res = await fetch(`/api/roadmap/${encodeURIComponent(id)}/vote`, { method: 'POST', credentials: 'include', }) if (res.ok) { const data: { count: number; voted: boolean } = await res.json() setVoteState((prev) => ({ ...prev, [id]: { count: data.count, voted: data.voted, loading: false }, })) } else { setVoteState((prev) => ({ ...prev, [id]: { ...current, loading: false } })) } } catch { setVoteState((prev) => ({ ...prev, [id]: { ...current, loading: false } })) } } useEffect(() => { const hash = window.location.hash.replace('#', '') as Tab if (TABS.some((t) => t.id === hash)) setActiveTab(hash) }, []) function handleTabChange(tab: Tab) { setActiveTab(tab) window.history.replaceState(null, '', `#${tab}`) } return (
{/* Tab bar */}
{TABS.map(({ id, label }) => ( ))}
{/* ── What's New ── */} {activeTab === 'whats-new' && (
{entries.length === 0 ? (

No releases yet — check back soon.

) : (
{entries.map((entry) => (

{entry.version}

{formatDate(entry.publishedAt)}

{(entry.tags?.length ?? 0) > 0 && (
{(entry.tags ?? []).map((tag) => ( {tag} ))}
)}

{entry.title}

{entry.body && (
)}
))}
)}
)} {/* ── What's Next ── */} {activeTab === 'whats-next' && (
{roadmapItems.length === 0 ? (

No roadmap items yet — check back soon.

) : ( <> {(() => { const inProgress = roadmapItems.filter((i) => i.status === 'in-progress') const planned = roadmapItems.filter((i) => i.status === 'planned') return ( <> {inProgress.length > 0 && (

In Progress

{inProgress.map((item) => ( ))}
)} {planned.length > 0 && (

Planned

{planned.map((item) => ( ))}
)} ) })()}

Don't see what you need?{' '}

{showSignInPrompt && (

Sign in {' '} to upvote roadmap items.

)} )}
)} {/* ── Give Feedback ── */} {activeTab === 'give-feedback' && (

Tell us what you think.

Bug, feature idea, or general thought — we read everything.

)}
) }