307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { PortableTextRenderer } from '@/components/PortableTextRenderer'
|
|
import { RoadmapItem } from '@/components/RoadmapItem'
|
|
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<string, string> = {
|
|
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<Tab>('whats-new')
|
|
|
|
const [voteState, setVoteState] = useState<Record<string, VoteState>>(
|
|
() =>
|
|
Object.fromEntries(
|
|
roadmapItems.map((item) => [
|
|
item._id,
|
|
{ count: 0, voted: false, loading: false },
|
|
])
|
|
)
|
|
)
|
|
|
|
const { user } = useAuth()
|
|
const [showSignInPrompt, setShowSignInPrompt] = useState(false)
|
|
const signInTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string, { count: number; voted: boolean }>) => {
|
|
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 (
|
|
<div>
|
|
{/* Tab bar */}
|
|
<div className="mb-10 flex border-b border-zinc-800">
|
|
{TABS.map(({ id, label }) => (
|
|
<button
|
|
key={id}
|
|
onClick={() => handleTabChange(id)}
|
|
className={[
|
|
'-mb-px border-b-2 px-5 py-2.5 text-sm font-medium transition-colors',
|
|
activeTab === id
|
|
? 'border-amber-400 text-amber-400'
|
|
: 'border-transparent text-zinc-500 hover:text-zinc-300',
|
|
].join(' ')}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── What's New ── */}
|
|
{activeTab === 'whats-new' && (
|
|
<div>
|
|
{entries.length === 0 ? (
|
|
<p className="text-sm text-zinc-500">
|
|
No releases yet — check back soon.
|
|
</p>
|
|
) : (
|
|
<div className="divide-y divide-zinc-900">
|
|
{entries.map((entry) => (
|
|
<div
|
|
key={entry._id}
|
|
className="grid grid-cols-[120px_1fr] gap-8 py-8"
|
|
>
|
|
<div className="pt-1">
|
|
<p className="font-mono text-xs font-bold text-amber-400">
|
|
{entry.version}
|
|
</p>
|
|
<p className="mt-1 text-[11px] text-zinc-600">
|
|
{formatDate(entry.publishedAt)}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
{(entry.tags?.length ?? 0) > 0 && (
|
|
<div className="mb-2.5 flex flex-wrap gap-1.5">
|
|
{(entry.tags ?? []).map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
|
|
TAG_STYLES[tag] ??
|
|
'border border-zinc-700 bg-zinc-800 text-zinc-400'
|
|
}`}
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<h3 className="mb-3 text-[17px] font-semibold text-white">
|
|
{entry.title}
|
|
</h3>
|
|
{entry.body && (
|
|
<div className="text-sm text-zinc-400">
|
|
<PortableTextRenderer value={entry.body} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── What's Next ── */}
|
|
{activeTab === 'whats-next' && (
|
|
<div>
|
|
{roadmapItems.length === 0 ? (
|
|
<p className="text-sm text-zinc-500">
|
|
No roadmap items yet — check back soon.
|
|
</p>
|
|
) : (
|
|
<>
|
|
{(() => {
|
|
const inProgress = roadmapItems.filter((i) => i.status === 'in-progress')
|
|
const planned = roadmapItems.filter((i) => i.status === 'planned')
|
|
return (
|
|
<>
|
|
{inProgress.length > 0 && (
|
|
<div className="mb-8">
|
|
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.18em] text-zinc-600">
|
|
In Progress
|
|
</p>
|
|
<div className="flex flex-col gap-2.5">
|
|
{inProgress.map((item) => (
|
|
<RoadmapItem
|
|
key={item._id}
|
|
id={item._id}
|
|
title={item.title}
|
|
description={item.description}
|
|
status={item.status}
|
|
vote={
|
|
voteState[item._id] ?? {
|
|
count: 0,
|
|
voted: false,
|
|
loading: false,
|
|
}
|
|
}
|
|
onVote={handleVote}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{planned.length > 0 && (
|
|
<div>
|
|
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.18em] text-zinc-600">
|
|
Planned
|
|
</p>
|
|
<div className="flex flex-col gap-2.5">
|
|
{planned.map((item) => (
|
|
<RoadmapItem
|
|
key={item._id}
|
|
id={item._id}
|
|
title={item.title}
|
|
description={item.description}
|
|
status={item.status}
|
|
vote={
|
|
voteState[item._id] ?? {
|
|
count: 0,
|
|
voted: false,
|
|
loading: false,
|
|
}
|
|
}
|
|
onVote={handleVote}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
})()}
|
|
|
|
<p className="mt-8 text-xs text-zinc-600">
|
|
Don't see what you need?{' '}
|
|
<button
|
|
onClick={() => handleTabChange('give-feedback')}
|
|
className="underline underline-offset-2 hover:text-zinc-300"
|
|
>
|
|
Give us feedback →
|
|
</button>
|
|
</p>
|
|
|
|
{showSignInPrompt && (
|
|
<p className="mt-3 text-xs text-amber-400">
|
|
<Link href="/login" className="underline underline-offset-2 hover:text-amber-300">
|
|
Sign in
|
|
</Link>{' '}
|
|
to upvote roadmap items.
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Give Feedback — stub, filled in Task 6 ── */}
|
|
{activeTab === 'give-feedback' && (
|
|
<p className="text-sm text-zinc-500">Feedback form coming soon.</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|