Replace the bespoke roadmap board/list toggle buttons (hardcoded blue-600) with the shared SegmentedControl, and wrap truncated board-card titles in Tooltip. Update the e2e toggle selector from button to radio for SegmentedControl. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
649 lines
25 KiB
TypeScript
649 lines
25 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useCallback } from 'react';
|
|
import { SegmentedControl } from '@/components/ui/Primitives';
|
|
import {
|
|
getRoadmapItems,
|
|
getRoadmapStats,
|
|
submitPublicItem,
|
|
publicVote,
|
|
type TrackerItem,
|
|
type PublicRoadmapStats,
|
|
} from '@/lib/tracker-client';
|
|
|
|
// ── Status column config ────────────────────────────────────────────
|
|
const STATUS_COLUMNS = [
|
|
{
|
|
key: 'open',
|
|
label: 'Planned',
|
|
color: 'bg-blue-500',
|
|
textColor: 'text-blue-700 dark:text-blue-300',
|
|
},
|
|
{
|
|
key: 'in_progress',
|
|
label: 'In Progress',
|
|
color: 'bg-amber-500',
|
|
textColor: 'text-amber-700 dark:text-amber-300',
|
|
},
|
|
{
|
|
key: 'done',
|
|
label: 'Complete',
|
|
color: 'bg-emerald-500',
|
|
textColor: 'text-emerald-700 dark:text-emerald-300',
|
|
},
|
|
] as const;
|
|
|
|
const TYPE_BADGES: Record<string, { label: string; className: string }> = {
|
|
feature: {
|
|
label: 'Feature',
|
|
className: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
|
},
|
|
bug: { label: 'Bug', className: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300' },
|
|
task: {
|
|
label: 'Task',
|
|
className: 'bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-300',
|
|
},
|
|
};
|
|
|
|
const PRIORITY_BADGES: Record<string, { label: string; className: string }> = {
|
|
critical: {
|
|
label: 'Critical',
|
|
className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
|
},
|
|
high: {
|
|
label: 'High',
|
|
className: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
|
|
},
|
|
medium: {
|
|
label: 'Medium',
|
|
className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
|
},
|
|
low: {
|
|
label: 'Low',
|
|
className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
|
},
|
|
};
|
|
|
|
export default function RoadmapPage() {
|
|
const [items, setItems] = useState<TrackerItem[]>([]);
|
|
const [stats, setStats] = useState<PublicRoadmapStats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState('');
|
|
const [search, setSearch] = useState('');
|
|
const [typeFilter, setTypeFilter] = useState('');
|
|
const [view, setView] = useState<'board' | 'list'>('board');
|
|
|
|
// Submit form state
|
|
const [showSubmit, setShowSubmit] = useState(false);
|
|
const [submitForm, setSubmitForm] = useState({
|
|
title: '',
|
|
description: '',
|
|
type: 'feature',
|
|
email: '',
|
|
name: '',
|
|
});
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [submitSuccess, setSubmitSuccess] = useState('');
|
|
|
|
// Vote email (persisted in localStorage)
|
|
const [voteEmail, setVoteEmail] = useState('');
|
|
const [showEmailPrompt, setShowEmailPrompt] = useState(false);
|
|
const [pendingVoteId, setPendingVoteId] = useState<string | null>(null);
|
|
const [votedItems, setVotedItems] = useState<Set<string>>(new Set());
|
|
const [voteError, setVoteError] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
const saved = localStorage.getItem('roadmap_email');
|
|
if (saved) setVoteEmail(saved);
|
|
const voted = localStorage.getItem('roadmap_voted');
|
|
if (voted) setVotedItems(new Set(JSON.parse(voted)));
|
|
}
|
|
}, []);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
setLoadError('');
|
|
try {
|
|
const params: Record<string, string> = {
|
|
sortBy: 'voteCount',
|
|
sortOrder: 'desc',
|
|
limit: '100',
|
|
};
|
|
if (search) params.q = search;
|
|
if (typeFilter) params.type = typeFilter;
|
|
const [itemsRes, statsRes] = await Promise.all([getRoadmapItems(params), getRoadmapStats()]);
|
|
setItems(itemsRes.items);
|
|
setStats(statsRes);
|
|
} catch (err) {
|
|
setLoadError(err instanceof Error ? err.message : 'Failed to load roadmap');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [search, typeFilter]);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
const handleVoteClick = (itemId: string) => {
|
|
if (!voteEmail) {
|
|
setPendingVoteId(itemId);
|
|
setShowEmailPrompt(true);
|
|
return;
|
|
}
|
|
doVote(itemId, voteEmail);
|
|
};
|
|
|
|
const doVote = async (itemId: string, email: string) => {
|
|
setVoteError('');
|
|
try {
|
|
const res = await publicVote(itemId, email);
|
|
setItems(prev => prev.map(i => (i.id === itemId ? { ...i, voteCount: res.voteCount } : i)));
|
|
const newVoted = new Set(votedItems);
|
|
if (res.voted) {
|
|
newVoted.add(itemId);
|
|
} else {
|
|
newVoted.delete(itemId);
|
|
}
|
|
setVotedItems(newVoted);
|
|
localStorage.setItem('roadmap_voted', JSON.stringify([...newVoted]));
|
|
} catch (err) {
|
|
setVoteError(err instanceof Error ? err.message : 'Vote failed');
|
|
}
|
|
};
|
|
|
|
const handleEmailSubmit = () => {
|
|
if (!voteEmail) return;
|
|
localStorage.setItem('roadmap_email', voteEmail);
|
|
setShowEmailPrompt(false);
|
|
if (pendingVoteId) {
|
|
doVote(pendingVoteId, voteEmail);
|
|
setPendingVoteId(null);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setSubmitting(true);
|
|
setSubmitSuccess('');
|
|
try {
|
|
const res = await submitPublicItem(submitForm);
|
|
setSubmitSuccess(
|
|
`Thanks! Your ${submitForm.type} request "${res.title}" has been submitted for review.`
|
|
);
|
|
setSubmitForm({
|
|
title: '',
|
|
description: '',
|
|
type: 'feature',
|
|
email: submitForm.email,
|
|
name: submitForm.name,
|
|
});
|
|
// Save email for voting
|
|
if (submitForm.email) {
|
|
setVoteEmail(submitForm.email);
|
|
localStorage.setItem('roadmap_email', submitForm.email);
|
|
}
|
|
// Refresh data to show new item
|
|
fetchData();
|
|
} catch (err) {
|
|
setSubmitSuccess(`Error: ${err instanceof Error ? err.message : 'Submission failed'}`);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const itemsByStatus = (status: string) => items.filter(i => i.status === status);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900">
|
|
{/* Header */}
|
|
<header className="border-b border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm sticky top-0 z-20">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Product Roadmap</h1>
|
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
|
|
Vote on features, report bugs, and shape what we build next
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => setShowSubmit(true)}
|
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
|
|
>
|
|
+ Submit Idea
|
|
</button>
|
|
<a
|
|
href="/login"
|
|
className="text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
|
>
|
|
Admin →
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
|
|
{loadError ? (
|
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-900/20 dark:text-red-300">
|
|
{loadError}
|
|
</div>
|
|
) : null}
|
|
{voteError ? (
|
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-900/20 dark:text-red-300">
|
|
{voteError}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Stats bar */}
|
|
{stats && (
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
|
|
<StatCard label="Total Items" value={stats.total} />
|
|
<StatCard label="Total Votes" value={stats.totalVotes} />
|
|
<StatCard label="In Progress" value={stats.byStatus?.in_progress || 0} />
|
|
<StatCard label="Completed" value={stats.byStatus?.done || 0} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
|
<input
|
|
type="text"
|
|
placeholder="Search items..."
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
className="flex-1 px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
<select
|
|
value={typeFilter}
|
|
onChange={e => setTypeFilter(e.target.value)}
|
|
aria-label="Filter by type"
|
|
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm"
|
|
>
|
|
<option value="">All Types</option>
|
|
<option value="feature">Features</option>
|
|
<option value="bug">Bugs</option>
|
|
<option value="task">Tasks</option>
|
|
</select>
|
|
<SegmentedControl
|
|
aria-label="Roadmap view"
|
|
value={view}
|
|
onValueChange={v => setView(v as 'board' | 'list')}
|
|
options={[
|
|
{ value: 'board', label: 'Board' },
|
|
{ value: 'list', label: 'List' },
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
{loading ? (
|
|
view === 'board' ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
{STATUS_COLUMNS.map(col => (
|
|
<div key={col.key} className="space-y-3">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className={`w-3 h-3 rounded-full ${col.color} animate-pulse`} />
|
|
<h2
|
|
className={`font-semibold ${col.textColor} h-5 w-24 bg-slate-200 dark:bg-slate-700 rounded animate-pulse`}
|
|
/>
|
|
</div>
|
|
{[1, 2, 3].map(i => (
|
|
<div
|
|
key={i}
|
|
className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex flex-col items-center min-w-[44px] py-1.5 px-2">
|
|
<div className="h-4 w-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
|
|
<div className="h-4 w-6 bg-slate-200 dark:bg-slate-700 rounded mt-1 animate-pulse" />
|
|
</div>
|
|
<div className="flex-1 min-w-0 space-y-2">
|
|
<div className="h-4 w-full bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
|
|
<div className="h-3 w-3/4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
|
|
<div className="flex gap-2 pt-2">
|
|
<div className="h-5 w-12 bg-slate-200 dark:bg-slate-700 rounded-full animate-pulse" />
|
|
<div className="h-5 w-12 bg-slate-200 dark:bg-slate-700 rounded-full animate-pulse" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{[1, 2, 3, 4, 5].map(i => (
|
|
<div
|
|
key={i}
|
|
className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 flex items-center gap-4"
|
|
>
|
|
<div className="flex flex-col items-center min-w-[44px] py-1.5 px-2">
|
|
<div className="h-4 w-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
|
|
<div className="h-4 w-6 bg-slate-200 dark:bg-slate-700 rounded mt-1 animate-pulse" />
|
|
</div>
|
|
<div className="flex-1 min-w-0 space-y-2">
|
|
<div className="h-4 w-full bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
|
|
<div className="h-3 w-1/2 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<div className="h-5 w-12 bg-slate-200 dark:bg-slate-700 rounded-full animate-pulse" />
|
|
<div className="h-4 w-16 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
) : view === 'board' ? (
|
|
/* Board View */
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
{STATUS_COLUMNS.map(col => (
|
|
<div key={col.key} className="space-y-3">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className={`w-3 h-3 rounded-full ${col.color}`} />
|
|
<h2 className={`font-semibold ${col.textColor}`}>{col.label}</h2>
|
|
<span className="text-xs text-slate-400 ml-auto">
|
|
{itemsByStatus(col.key).length}
|
|
</span>
|
|
</div>
|
|
{itemsByStatus(col.key).length === 0 ? (
|
|
<p className="text-sm text-slate-400 italic py-4 text-center">No items</p>
|
|
) : (
|
|
itemsByStatus(col.key).map(item => (
|
|
<ItemCard
|
|
key={item.id}
|
|
item={item}
|
|
votedItems={votedItems}
|
|
onVote={handleVoteClick}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
/* List View */
|
|
<div className="space-y-3">
|
|
{items.length === 0 ? (
|
|
<p className="text-center py-10 text-slate-400">No items found</p>
|
|
) : (
|
|
items.map(item => (
|
|
<ItemRow
|
|
key={item.id}
|
|
item={item}
|
|
votedItems={votedItems}
|
|
onVote={handleVoteClick}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{/* Email prompt modal */}
|
|
{showEmailPrompt && (
|
|
<Modal
|
|
onClose={() => {
|
|
setShowEmailPrompt(false);
|
|
setPendingVoteId(null);
|
|
}}
|
|
>
|
|
<h3 className="text-lg font-semibold mb-2">Enter your email to vote</h3>
|
|
<p className="text-sm text-slate-500 mb-4">
|
|
We use your email to track your votes. One vote per item.
|
|
</p>
|
|
<input
|
|
type="email"
|
|
placeholder="you@example.com"
|
|
value={voteEmail}
|
|
onChange={e => setVoteEmail(e.target.value)}
|
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm mb-4"
|
|
onKeyDown={e => e.key === 'Enter' && handleEmailSubmit()}
|
|
autoFocus
|
|
/>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => {
|
|
setShowEmailPrompt(false);
|
|
setPendingVoteId(null);
|
|
}}
|
|
className="px-4 py-2 text-sm text-slate-500 hover:text-slate-700"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleEmailSubmit}
|
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium"
|
|
>
|
|
Vote
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
|
|
{/* Submit modal */}
|
|
{showSubmit && (
|
|
<Modal
|
|
onClose={() => {
|
|
setShowSubmit(false);
|
|
setSubmitSuccess('');
|
|
}}
|
|
>
|
|
<h3 className="text-lg font-semibold mb-4">Submit an Idea</h3>
|
|
{submitSuccess ? (
|
|
<div
|
|
className={`p-3 rounded-lg text-sm mb-4 ${submitSuccess.startsWith('Error') ? 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-300' : 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-300'}`}
|
|
>
|
|
{submitSuccess}
|
|
</div>
|
|
) : null}
|
|
<form onSubmit={handleSubmit} className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<input
|
|
type="text"
|
|
placeholder="Your name"
|
|
value={submitForm.name}
|
|
onChange={e => setSubmitForm({ ...submitForm, name: e.target.value })}
|
|
required
|
|
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm"
|
|
/>
|
|
<input
|
|
type="email"
|
|
placeholder="Your email"
|
|
value={submitForm.email}
|
|
onChange={e => setSubmitForm({ ...submitForm, email: e.target.value })}
|
|
required
|
|
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm"
|
|
/>
|
|
</div>
|
|
<select
|
|
value={submitForm.type}
|
|
onChange={e => setSubmitForm({ ...submitForm, type: e.target.value })}
|
|
aria-label="Request type"
|
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm"
|
|
>
|
|
<option value="feature">Feature Request</option>
|
|
<option value="bug">Bug Report</option>
|
|
<option value="task">Task</option>
|
|
</select>
|
|
<input
|
|
type="text"
|
|
placeholder="Title — what would you like to see?"
|
|
value={submitForm.title}
|
|
onChange={e => setSubmitForm({ ...submitForm, title: e.target.value })}
|
|
required
|
|
maxLength={500}
|
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm"
|
|
/>
|
|
<textarea
|
|
placeholder="Describe your idea or issue in detail (optional)"
|
|
value={submitForm.description}
|
|
onChange={e => setSubmitForm({ ...submitForm, description: e.target.value })}
|
|
rows={4}
|
|
maxLength={5000}
|
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm resize-none"
|
|
/>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowSubmit(false)}
|
|
className="px-4 py-2 text-sm text-slate-500 hover:text-slate-700"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={submitting}
|
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
|
>
|
|
{submitting ? 'Submitting...' : 'Submit'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Sub-components ──────────────────────────────────────────────────
|
|
|
|
function StatCard({ label, value }: { label: string; value: number }) {
|
|
return (
|
|
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
|
<p className="text-2xl font-bold text-slate-900 dark:text-white">{value}</p>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">{label}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ItemCard({
|
|
item,
|
|
votedItems,
|
|
onVote,
|
|
}: {
|
|
item: TrackerItem;
|
|
votedItems: Set<string>;
|
|
onVote: (id: string) => void;
|
|
}) {
|
|
const typeBadge = TYPE_BADGES[item.type] || TYPE_BADGES.task;
|
|
const priorityBadge = PRIORITY_BADGES[item.priority] || PRIORITY_BADGES.medium;
|
|
const hasVoted = votedItems.has(item.id);
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 hover:shadow-md transition-shadow">
|
|
<div className="flex items-start gap-3">
|
|
<button
|
|
onClick={() => onVote(item.id)}
|
|
type="button"
|
|
aria-pressed={hasVoted}
|
|
aria-label={hasVoted ? `Remove vote from ${item.title}` : `Upvote ${item.title}`}
|
|
className={`flex flex-col items-center min-w-[44px] py-1.5 px-2 rounded-lg border text-sm font-semibold transition-colors ${
|
|
hasVoted
|
|
? 'bg-blue-50 border-blue-300 text-blue-700 dark:bg-blue-900/30 dark:border-blue-700 dark:text-blue-300'
|
|
: 'bg-slate-50 border-slate-200 text-slate-500 hover:border-blue-300 hover:text-blue-600 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-300'
|
|
}`}
|
|
title={hasVoted ? 'Remove vote' : 'Upvote'}
|
|
>
|
|
<span className="text-xs">▲</span>
|
|
<span>{item.voteCount}</span>
|
|
</button>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-medium text-slate-900 dark:text-white text-sm leading-snug">
|
|
{item.title}
|
|
</h3>
|
|
{item.description && (
|
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1 line-clamp-2">
|
|
{item.description}
|
|
</p>
|
|
)}
|
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
|
<span
|
|
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${typeBadge.className}`}
|
|
>
|
|
{typeBadge.label}
|
|
</span>
|
|
<span
|
|
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${priorityBadge.className}`}
|
|
>
|
|
{priorityBadge.label}
|
|
</span>
|
|
{item.commentCount > 0 && (
|
|
<span className="text-[10px] text-slate-400">💬 {item.commentCount}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ItemRow({
|
|
item,
|
|
votedItems,
|
|
onVote,
|
|
}: {
|
|
item: TrackerItem;
|
|
votedItems: Set<string>;
|
|
onVote: (id: string) => void;
|
|
}) {
|
|
const typeBadge = TYPE_BADGES[item.type] || TYPE_BADGES.task;
|
|
const statusCol = STATUS_COLUMNS.find(c => c.key === item.status);
|
|
const hasVoted = votedItems.has(item.id);
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 flex items-center gap-4 hover:shadow-md transition-shadow">
|
|
<button
|
|
onClick={() => onVote(item.id)}
|
|
type="button"
|
|
aria-pressed={hasVoted}
|
|
aria-label={hasVoted ? `Remove vote from ${item.title}` : `Upvote ${item.title}`}
|
|
title={hasVoted ? 'Remove vote' : 'Upvote'}
|
|
className={`flex flex-col items-center min-w-[44px] py-1.5 px-2 rounded-lg border text-sm font-semibold transition-colors ${
|
|
hasVoted
|
|
? 'bg-blue-50 border-blue-300 text-blue-700 dark:bg-blue-900/30 dark:border-blue-700 dark:text-blue-300'
|
|
: 'bg-slate-50 border-slate-200 text-slate-500 hover:border-blue-300 hover:text-blue-600 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-300'
|
|
}`}
|
|
>
|
|
<span className="text-xs">▲</span>
|
|
<span>{item.voteCount}</span>
|
|
</button>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-medium text-slate-900 dark:text-white text-sm">{item.title}</h3>
|
|
{item.description && (
|
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-1">
|
|
{item.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span
|
|
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${typeBadge.className}`}
|
|
>
|
|
{typeBadge.label}
|
|
</span>
|
|
{statusCol && (
|
|
<span className="flex items-center gap-1 text-xs text-slate-500">
|
|
<span className={`w-2 h-2 rounded-full ${statusCol.color}`} />
|
|
{statusCol.label}
|
|
</span>
|
|
)}
|
|
{item.commentCount > 0 && (
|
|
<span className="text-xs text-slate-400">💬 {item.commentCount}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
|
|
<div className="relative bg-white dark:bg-slate-800 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-700 p-6 w-full max-w-md">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|