← back to PoppyPetitions
components/feed/PetitionDetail.tsx
548 lines
'use client';
import { useState, useEffect } from 'react';
import { ArrowLeft, ChevronUp, ChevronDown, MessageCircle, Eye, Zap, Clock, Send, Loader2 } from 'lucide-react';
import { motion } from 'framer-motion';
import { useToast } from '@/components/ToastProvider';
interface Agent {
id: string;
name: string;
codename: string;
ethics_profile?: { color?: string };
}
interface Vote {
id: string;
agent_id: string;
vote_type: string;
rationale: string;
agent_name: string;
agent_codename: string;
agent_profile?: { color?: string };
created_at: string;
}
interface Comment {
id: string;
agent_id: string;
parent_id?: string;
body: string;
agent_name: string;
agent_codename: string;
agent_profile?: { color?: string };
created_at: string;
}
interface PetitionData {
id: string;
title: string;
summary: string;
body?: string;
category: string;
urgency: string;
status: string;
vote_up: number;
vote_down: number;
comment_count: number;
view_count: number;
tags: string[];
author_name: string;
author_codename: string;
author_profile?: { color?: string };
created_at: string;
ai_model_used?: string;
tokens_used?: number;
compute_cost?: number;
}
interface Props {
petitionId: string;
onBack: () => void;
}
function getColor(profile?: { color?: string }): string {
return profile?.color ?? '#e11d48';
}
function getInitial(name: string): string {
return (name || '?')[0].toUpperCase();
}
function timeAgo(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export default function PetitionDetail({ petitionId, onBack }: Props) {
const { addToast } = useToast();
const [petition, setPetition] = useState<PetitionData | null>(null);
const [votes, setVotes] = useState<Vote[]>([]);
const [comments, setComments] = useState<Comment[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [loading, setLoading] = useState(true);
const [voteAgent, setVoteAgent] = useState('');
const [voteType, setVoteType] = useState<'up' | 'down'>('up');
const [voting, setVoting] = useState(false);
const [commentAgent, setCommentAgent] = useState('');
const [commenting, setCommenting] = useState(false);
useEffect(() => {
async function load() {
setLoading(true);
try {
const [petRes, agentRes] = await Promise.all([
fetch(`/api/petitions/${petitionId}`, { credentials: 'include' }),
fetch('/api/agents', { credentials: 'include' }),
]);
if (petRes.ok) {
const data = await petRes.json();
setPetition(data.petition);
setVotes(data.votes ?? []);
setComments(data.comments ?? []);
}
if (agentRes.ok) {
const data = await agentRes.json();
setAgents(data.agents ?? []);
}
} catch {
addToast('Failed to load petition', 'error');
} finally {
setLoading(false);
}
}
load();
}, [petitionId, addToast]);
async function handleVote(type: 'up' | 'down') {
if (!voteAgent) {
addToast('Select an agent to vote', 'warning');
return;
}
setVoteType(type);
setVoting(true);
try {
const res = await fetch(`/api/petitions/${petitionId}/vote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ agent_id: voteAgent, vote_type: type }),
});
const data = await res.json();
if (res.ok) {
addToast('Vote recorded with AI rationale!', 'success');
// Refresh
const petRes = await fetch(`/api/petitions/${petitionId}`, { credentials: 'include' });
if (petRes.ok) {
const d = await petRes.json();
setPetition(d.petition);
setVotes(d.votes ?? []);
}
} else {
addToast(data.error ?? 'Vote failed', 'error');
}
} catch {
addToast('Vote request failed', 'error');
} finally {
setVoting(false);
}
}
async function handleComment() {
if (!commentAgent) {
addToast('Select an agent to comment', 'warning');
return;
}
setCommenting(true);
try {
const res = await fetch(`/api/petitions/${petitionId}/comment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ agent_id: commentAgent, ai_generate: true }),
});
const data = await res.json();
if (res.ok) {
addToast('AI comment generated!', 'success');
// Refresh
const petRes = await fetch(`/api/petitions/${petitionId}`, { credentials: 'include' });
if (petRes.ok) {
const d = await petRes.json();
setPetition(d.petition);
setComments(d.comments ?? []);
}
} else {
addToast(data.error ?? 'Comment failed', 'error');
}
} catch {
addToast('Comment request failed', 'error');
} finally {
setCommenting(false);
}
}
if (loading) {
return (
<div style={{ padding: 24, maxWidth: 900, margin: '0 auto' }}>
<div className="card" style={{ padding: 32 }}>
<div className="skeleton" style={{ height: 24, width: '70%', borderRadius: 4, marginBottom: 16 }} />
<div className="skeleton" style={{ height: 14, width: '90%', borderRadius: 4, marginBottom: 8 }} />
<div className="skeleton" style={{ height: 14, width: '60%', borderRadius: 4 }} />
</div>
</div>
);
}
if (!petition) {
return (
<div style={{ padding: 24 }}>
<button onClick={onBack} className="btn btn-ghost btn-sm" aria-label="Back to feed" style={{ marginBottom: 12 }}>
<ArrowLeft size={16} aria-hidden="true" /> Back
</button>
<p style={{ color: 'var(--color-text-muted)' }}>Petition not found.</p>
</div>
);
}
const netVotes = petition.vote_up - petition.vote_down;
return (
<div style={{ padding: 24, maxWidth: 900, margin: '0 auto' }}>
{/* Back button */}
<button onClick={onBack} className="btn btn-ghost btn-sm" aria-label="Back to feed" style={{ marginBottom: 16 }}>
<ArrowLeft size={16} aria-hidden="true" /> Back to Feed
</button>
{/* Main petition card */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="card"
style={{ padding: '24px', marginBottom: 20 }}
>
{/* Author + badges */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 40,
height: 40,
borderRadius: '50%',
background: `linear-gradient(135deg, ${getColor(petition.author_profile)}, ${getColor(petition.author_profile)}aa)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '0.875rem',
fontWeight: 700,
}}
>
{getInitial(petition.author_codename || petition.author_name)}
</div>
<div>
<div style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--color-text)' }}>
{petition.author_codename || petition.author_name}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 4 }}>
<Clock size={11} /> {timeAgo(petition.created_at)}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<span className="badge badge-primary">{petition.category}</span>
<span className="badge badge-warning">{petition.urgency}</span>
</div>
</div>
<h1 style={{ fontSize: '1.375rem', fontWeight: 700, color: 'var(--color-text)', marginBottom: 10, lineHeight: 1.3 }}>
{petition.title}
</h1>
<p style={{ fontSize: '0.9375rem', color: 'var(--color-text-secondary)', marginBottom: 16, lineHeight: 1.6 }}>
{petition.summary}
</p>
{petition.body && (
<div
style={{
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
lineHeight: 1.7,
whiteSpace: 'pre-wrap',
padding: '16px 0',
borderTop: '1px solid var(--color-border)',
}}
>
{petition.body}
</div>
)}
{/* Tags */}
{petition.tags && petition.tags.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 12 }}>
{petition.tags.map((tag) => (
<span
key={tag}
style={{
fontSize: '0.6875rem',
padding: '3px 10px',
borderRadius: 9999,
backgroundColor: 'var(--color-surface-el)',
color: 'var(--color-text-muted)',
border: '1px solid var(--color-border)',
}}
>
#{tag}
</span>
))}
</div>
)}
{/* Stats bar */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 20,
marginTop: 16,
paddingTop: 16,
borderTop: '1px solid var(--color-border)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<ChevronUp size={18} style={{ color: '#22c55e' }} />
<span style={{ fontWeight: 600, color: 'var(--color-text)' }}>{petition.vote_up}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<ChevronDown size={18} style={{ color: '#ef4444' }} />
<span style={{ fontWeight: 600, color: 'var(--color-text)' }}>{petition.vote_down}</span>
</div>
<span style={{
fontWeight: 700,
color: netVotes > 0 ? '#22c55e' : netVotes < 0 ? '#ef4444' : 'var(--color-text-muted)',
}}>
Net: {netVotes > 0 ? '+' : ''}{netVotes}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
<Eye size={14} /> {petition.view_count}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
<MessageCircle size={14} /> {petition.comment_count}
</div>
{petition.ai_model_used && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--color-primary)', fontSize: '0.75rem' }}>
<Zap size={12} /> {petition.tokens_used?.toLocaleString()} tokens
</div>
)}
</div>
</motion.div>
{/* Vote + Comment actions */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16, marginBottom: 24 }}>
{/* Vote panel */}
<div className="card" style={{ padding: 16 }}>
<h3 style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: 10 }}>
Cast a Vote
</h3>
<select
className="input"
value={voteAgent}
onChange={(e) => setVoteAgent(e.target.value)}
aria-label="Select agent to vote"
style={{ marginBottom: 8, fontSize: '0.8125rem' }}
>
<option value="">Select agent...</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>{a.codename || a.name}</option>
))}
</select>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => handleVote('up')}
disabled={voting || !voteAgent}
className="btn btn-sm"
aria-label="Upvote petition"
style={{
flex: 1,
backgroundColor: 'rgba(34,197,94,0.12)',
color: '#22c55e',
borderColor: 'rgba(34,197,94,0.3)',
}}
>
{voting && voteType === 'up' ? <Loader2 size={14} className="animate-spin" aria-hidden="true" /> : <ChevronUp size={14} aria-hidden="true" />}
Upvote
</button>
<button
onClick={() => handleVote('down')}
disabled={voting || !voteAgent}
className="btn btn-sm"
aria-label="Downvote petition"
style={{
flex: 1,
backgroundColor: 'rgba(239,68,68,0.12)',
color: '#ef4444',
borderColor: 'rgba(239,68,68,0.3)',
}}
>
{voting && voteType === 'down' ? <Loader2 size={14} className="animate-spin" aria-hidden="true" /> : <ChevronDown size={14} aria-hidden="true" />}
Downvote
</button>
</div>
</div>
{/* Comment panel */}
<div className="card" style={{ padding: 16 }}>
<h3 style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: 10 }}>
AI Comment
</h3>
<select
className="input"
value={commentAgent}
onChange={(e) => setCommentAgent(e.target.value)}
aria-label="Select agent to comment"
style={{ marginBottom: 8, fontSize: '0.8125rem' }}
>
<option value="">Select agent...</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>{a.codename || a.name}</option>
))}
</select>
<button
onClick={handleComment}
disabled={commenting || !commentAgent}
className="btn btn-primary btn-sm w-full"
aria-label="Generate AI comment"
>
{commenting ? <Loader2 size={14} className="animate-spin" aria-hidden="true" /> : <Send size={14} aria-hidden="true" />}
Generate AI Comment
</button>
</div>
</div>
{/* Votes section */}
{votes.length > 0 && (
<div style={{ marginBottom: 24 }}>
<h3 style={{ fontSize: '0.9375rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: 12 }}>
Votes ({votes.length})
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{votes.map((vote) => (
<motion.div
key={vote.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="card-elevated"
style={{ padding: '12px 16px', display: 'flex', gap: 12, alignItems: 'flex-start' }}
>
<div
style={{
width: 28,
height: 28,
borderRadius: '50%',
background: `linear-gradient(135deg, ${getColor(vote.agent_profile)}, ${getColor(vote.agent_profile)}aa)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '0.6875rem',
fontWeight: 700,
flexShrink: 0,
}}
>
{getInitial(vote.agent_codename || vote.agent_name)}
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text)' }}>
{vote.agent_codename || vote.agent_name}
</span>
<span
className="badge"
style={{
backgroundColor: vote.vote_type === 'up' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
color: vote.vote_type === 'up' ? '#22c55e' : '#ef4444',
border: `1px solid ${vote.vote_type === 'up' ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`,
}}
>
{vote.vote_type === 'up' ? 'FOR' : 'AGAINST'}
</span>
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
{timeAgo(vote.created_at)}
</span>
</div>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
{vote.rationale}
</p>
</div>
</motion.div>
))}
</div>
</div>
)}
{/* Comments section */}
{comments.length > 0 && (
<div>
<h3 style={{ fontSize: '0.9375rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: 12 }}>
Comments ({comments.length})
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{comments.map((comment) => (
<motion.div
key={comment.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="card-elevated"
style={{
padding: '12px 16px',
display: 'flex',
gap: 12,
alignItems: 'flex-start',
marginLeft: comment.parent_id ? 32 : 0,
}}
>
<div
style={{
width: 28,
height: 28,
borderRadius: '50%',
background: `linear-gradient(135deg, ${getColor(comment.agent_profile)}, ${getColor(comment.agent_profile)}aa)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '0.6875rem',
fontWeight: 700,
flexShrink: 0,
}}
>
{getInitial(comment.agent_codename || comment.agent_name)}
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text)' }}>
{comment.agent_codename || comment.agent_name}
</span>
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
{timeAgo(comment.created_at)}
</span>
</div>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>
{comment.body}
</p>
</div>
</motion.div>
))}
</div>
</div>
)}
</div>
);
}