367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
import { MOCK_EVENTS, MOCK_POLLS } from '../../data/mockData';
|
|
import { useAuth } from '../../contexts/AuthContext';
|
|
import { formatDateTime } from '../../utils/format';
|
|
import type { EventPost, EventType, Poll, UserRole } from '../../types';
|
|
|
|
const EVENT_TYPE_COLORS: Record<EventType, string> = {
|
|
announcement: 'var(--color-yellow)',
|
|
update: 'var(--color-blue)',
|
|
milestone: 'var(--color-green)',
|
|
poll: 'var(--color-amber)',
|
|
};
|
|
|
|
const EVENT_TYPE_LABELS: Record<EventType, string> = {
|
|
announcement: 'ANNOUNCEMENT',
|
|
update: 'DEV UPDATE',
|
|
milestone: 'MILESTONE',
|
|
poll: 'COMMUNITY POLL',
|
|
};
|
|
|
|
const ROLE_COLORS: Record<UserRole, string> = {
|
|
dev: 'var(--color-green)',
|
|
com: 'var(--color-amber)',
|
|
user: 'var(--color-text-muted)',
|
|
};
|
|
|
|
// ── Poll Component ─────────────────────────────────────────────────────────────
|
|
|
|
function PollCard({ poll, onVote }: { poll: Poll; onVote: (pollId: string, optionId: string) => void }) {
|
|
const { user } = useAuth();
|
|
const totalVotes = poll.options.reduce((sum, opt) => sum + opt.votes, 0);
|
|
const isEnded = poll.endsAt ? new Date(poll.endsAt) < new Date() : false;
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
background: 'var(--color-bg-alt)',
|
|
border: '1px solid var(--color-border)',
|
|
padding: '1rem',
|
|
marginTop: '0.75rem',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
fontFamily: 'var(--font-mono)',
|
|
fontSize: '0.85rem',
|
|
color: 'var(--color-text)',
|
|
marginBottom: '0.85rem',
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{poll.question}
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
|
{poll.options.map((option) => {
|
|
const percentage = totalVotes > 0 ? Math.round((option.votes / totalVotes) * 100) : 0;
|
|
const userVoted = option.votedUserIds.includes(user?.id || '');
|
|
|
|
return (
|
|
<div
|
|
key={option.id}
|
|
style={{
|
|
position: 'relative',
|
|
background: 'var(--color-surface)',
|
|
border: `1px solid ${userVoted ? 'var(--color-amber)' : 'var(--color-border)'}`,
|
|
padding: '0.6rem 0.75rem',
|
|
cursor: !isEnded && poll.isActive && user ? 'pointer' : 'default',
|
|
opacity: isEnded || !poll.isActive ? 0.7 : 1,
|
|
transition: 'border-color 0.2s',
|
|
}}
|
|
onClick={() => {
|
|
if (!isEnded && poll.isActive && user) {
|
|
onVote(poll.id, option.id);
|
|
}
|
|
}}
|
|
>
|
|
{/* Progress bar */}
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
bottom: 0,
|
|
width: `${percentage}%`,
|
|
background: userVoted
|
|
? 'rgba(217,119,6,0.15)'
|
|
: 'rgba(59,130,246,0.1)',
|
|
transition: 'width 0.3s ease',
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
position: 'relative',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
fontFamily: 'var(--font-mono)',
|
|
fontSize: '0.75rem',
|
|
}}
|
|
>
|
|
<span style={{ color: 'var(--color-text-dim)' }}>
|
|
{userVoted && '✓ '}
|
|
{option.text}
|
|
</span>
|
|
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.7rem' }}>
|
|
{option.votes} ({percentage}%)
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div
|
|
style={{
|
|
marginTop: '0.75rem',
|
|
fontFamily: 'var(--font-mono)',
|
|
fontSize: '0.65rem',
|
|
color: 'var(--color-text-muted)',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
flexWrap: 'wrap',
|
|
gap: '0.5rem',
|
|
}}
|
|
>
|
|
<span>
|
|
{totalVotes} total vote{totalVotes !== 1 ? 's' : ''}
|
|
{poll.allowMultipleVotes && ' • Multiple votes allowed'}
|
|
</span>
|
|
{poll.endsAt && (
|
|
<span style={{ color: isEnded ? 'var(--color-red)' : 'var(--color-amber)' }}>
|
|
{isEnded ? 'Poll Ended' : `Ends ${formatDateTime(poll.endsAt)}`}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{!user && !isEnded && poll.isActive && (
|
|
<div
|
|
style={{
|
|
marginTop: '0.75rem',
|
|
padding: '0.5rem',
|
|
background: 'rgba(217,119,6,0.1)',
|
|
border: '1px solid rgba(217,119,6,0.25)',
|
|
fontFamily: 'var(--font-mono)',
|
|
fontSize: '0.7rem',
|
|
color: 'var(--color-amber)',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
Please <a href="/login" style={{ color: 'var(--color-amber)', textDecoration: 'underline' }}>log in</a> to vote
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Event Card Component ───────────────────────────────────────────────────────
|
|
|
|
function EventCard({
|
|
event,
|
|
poll,
|
|
onVote,
|
|
}: {
|
|
event: EventPost;
|
|
poll?: Poll;
|
|
onVote: (pollId: string, optionId: string) => void;
|
|
}) {
|
|
return (
|
|
<div
|
|
style={{
|
|
background: 'var(--color-surface)',
|
|
border: '1px solid var(--color-border)',
|
|
padding: '1.5rem',
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '0.75rem',
|
|
marginBottom: '1rem',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
<span
|
|
style={{
|
|
fontFamily: 'var(--font-mono)',
|
|
background: `${EVENT_TYPE_COLORS[event.type]}15`,
|
|
border: `1px solid ${EVENT_TYPE_COLORS[event.type]}40`,
|
|
color: EVENT_TYPE_COLORS[event.type],
|
|
fontSize: '0.6rem',
|
|
padding: '0.2rem 0.5rem',
|
|
letterSpacing: '0.08em',
|
|
}}
|
|
>
|
|
{EVENT_TYPE_LABELS[event.type]}
|
|
</span>
|
|
</div>
|
|
<h2
|
|
style={{
|
|
fontFamily: 'var(--font-heading)',
|
|
color: 'var(--color-text)',
|
|
fontSize: 'clamp(1.25rem, 4vw, 1.5rem)',
|
|
lineHeight: 1.2,
|
|
}}
|
|
>
|
|
{event.title}
|
|
</h2>
|
|
<div
|
|
style={{
|
|
fontFamily: 'var(--font-mono)',
|
|
color: 'var(--color-text-muted)',
|
|
fontSize: '0.7rem',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '0.5rem',
|
|
flexWrap: 'wrap',
|
|
}}
|
|
>
|
|
<span style={{ color: ROLE_COLORS[event.authorRole] }}>
|
|
{event.authorName}
|
|
</span>
|
|
<span>•</span>
|
|
<span>{formatDateTime(event.createdAt)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div
|
|
style={{
|
|
fontFamily: 'var(--font-mono)',
|
|
color: 'var(--color-text-dim)',
|
|
fontSize: '0.88rem',
|
|
lineHeight: 1.75,
|
|
whiteSpace: 'pre-wrap',
|
|
}}
|
|
>
|
|
{event.content}
|
|
</div>
|
|
|
|
{/* Poll if exists */}
|
|
{poll && <PollCard poll={poll} onVote={onVote} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Main Component ─────────────────────────────────────────────────────────────
|
|
|
|
export default function EventsPage() {
|
|
const { user } = useAuth();
|
|
// Filter to show only public events
|
|
const publicEvents = MOCK_EVENTS.filter((event) => event.isPublic);
|
|
const [events] = useState<EventPost[]>(publicEvents);
|
|
const [polls, setPolls] = useState<Poll[]>(MOCK_POLLS);
|
|
|
|
const handleVote = useCallback(
|
|
(pollId: string, optionId: string) => {
|
|
if (!user) return;
|
|
|
|
setPolls((prevPolls) =>
|
|
prevPolls.map((poll) => {
|
|
if (poll.id !== pollId) return poll;
|
|
|
|
const hasVotedForOption = poll.options.some((opt) =>
|
|
opt.votedUserIds.includes(user.id)
|
|
);
|
|
|
|
return {
|
|
...poll,
|
|
options: poll.options.map((opt) => {
|
|
if (opt.id === optionId) {
|
|
// Add vote to this option
|
|
return {
|
|
...opt,
|
|
votes: opt.votedUserIds.includes(user.id) ? opt.votes : opt.votes + 1,
|
|
votedUserIds: opt.votedUserIds.includes(user.id)
|
|
? opt.votedUserIds
|
|
: [...opt.votedUserIds, user.id],
|
|
};
|
|
} else if (!poll.allowMultipleVotes && hasVotedForOption) {
|
|
// Remove vote from other options if single vote
|
|
return {
|
|
...opt,
|
|
votes: opt.votedUserIds.includes(user.id) ? opt.votes - 1 : opt.votes,
|
|
votedUserIds: opt.votedUserIds.filter((id) => id !== user.id),
|
|
};
|
|
}
|
|
return opt;
|
|
}),
|
|
};
|
|
})
|
|
);
|
|
},
|
|
[user]
|
|
);
|
|
|
|
return (
|
|
<div className="page-wrapper">
|
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
|
{/* Header */}
|
|
<div style={{ marginBottom: '3rem', textAlign: 'center' }}>
|
|
<div
|
|
className="section-label"
|
|
style={{ marginBottom: '0.75rem' }}
|
|
>
|
|
DEVELOPMENT UPDATES
|
|
</div>
|
|
<h1
|
|
style={{
|
|
fontFamily: 'var(--font-heading)',
|
|
color: 'var(--color-text)',
|
|
fontSize: 'clamp(2rem, 6vw, 3rem)',
|
|
marginBottom: '1rem',
|
|
letterSpacing: '0.05em',
|
|
}}
|
|
>
|
|
COMMUNITY EVENTS
|
|
</h1>
|
|
<p
|
|
style={{
|
|
fontFamily: 'var(--font-mono)',
|
|
color: 'var(--color-text-muted)',
|
|
fontSize: '0.85rem',
|
|
maxWidth: '600px',
|
|
margin: '0 auto',
|
|
lineHeight: 1.6,
|
|
}}
|
|
>
|
|
Stay up to date with the latest game development news, announcements, and participate
|
|
in community polls to help shape the future of Headless Hazard.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Events Grid */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
|
{events.length === 0 ? (
|
|
<div
|
|
style={{
|
|
background: 'var(--color-surface)',
|
|
border: '1px solid var(--color-border)',
|
|
padding: '3rem 2rem',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
fontFamily: 'var(--font-mono)',
|
|
color: 'var(--color-text-muted)',
|
|
fontSize: '0.85rem',
|
|
}}
|
|
>
|
|
No events available at the moment. Check back soon!
|
|
</div>
|
|
</div>
|
|
) : (
|
|
events.map((event) => {
|
|
const poll = event.pollId ? polls.find((p) => p.id === event.pollId) : undefined;
|
|
return <EventCard key={event.id} event={event} poll={poll} onVote={handleVote} />;
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|