feat: add events and polls functionality to intranet and public pages

This commit is contained in:
Thibault Pouch
2026-02-18 12:48:16 +01:00
parent c5b4b44bb8
commit 308a758e79
7 changed files with 1249 additions and 0 deletions

View File

@@ -10,6 +10,7 @@ import { PageLoader } from './components/shared/PageLoader';
const HomePage = lazy(() => import('./pages/public/HomePage'));
const StudioPage = lazy(() => import('./pages/public/StudioPage'));
const EventsPage = lazy(() => import('./pages/public/EventsPage'));
const ForumPage = lazy(() => import('./pages/public/ForumPage'));
const ThreadPage = lazy(() => import('./pages/public/ThreadPage'));
const BugReportPage = lazy(() => import('./pages/public/BugReportPage'));
@@ -24,6 +25,7 @@ const NotFoundPage = lazy(() => import('./pages/public/NotFoundPage'));
const IntranetDashboard = lazy(() => import('./pages/intranet/IntranetDashboard'));
const IntranetBugs = lazy(() => import('./pages/intranet/IntranetBugs'));
const IntranetFeed = lazy(() => import('./pages/intranet/IntranetFeed'));
const IntranetEvents = lazy(() => import('./pages/intranet/IntranetEvents'));
const IntranetUsers = lazy(() => import('./pages/intranet/IntranetUsers'));
const IntranetModeration = lazy(() => import('./pages/intranet/IntranetModeration'));
@@ -38,6 +40,7 @@ export default function App() {
<Route element={<PublicLayout />}>
<Route index element={<HomePage />} />
<Route path="studio" element={<StudioPage />} />
<Route path="events" element={<EventsPage />} />
<Route path="forum" element={<ForumPage />} />
<Route path="forum/thread/:id" element={<ThreadPage />} />
<Route path="bugs" element={<BugReportPage />} />
@@ -67,6 +70,7 @@ export default function App() {
<Route index element={<IntranetDashboard />} />
<Route path="bugs" element={<IntranetBugs />} />
<Route path="feed" element={<IntranetFeed />} />
<Route path="events" element={<IntranetEvents />} />
<Route path="users" element={<IntranetUsers />} />
<Route path="moderation" element={<IntranetModeration />} />
</Route>

View File

@@ -6,6 +6,7 @@ const INTRANET_LINKS = [
{ to: '/intranet', label: 'Dashboard', icon: '[>]', end: true },
{ to: '/intranet/bugs', label: 'Bug Reports', icon: '[!]', end: false },
{ to: '/intranet/feed', label: 'Team Feed', icon: '[~]', end: false },
{ to: '/intranet/events', label: 'Events', icon: '[E]', end: false },
{ to: '/intranet/users', label: 'Users', icon: '[U]', end: false },
{ to: '/intranet/moderation', label: 'Moderation', icon: '[M]', end: false },
];

View File

@@ -5,6 +5,7 @@ import { useAuth } from '../../contexts/AuthContext';
const NAV_LINKS = [
{ to: '/', label: 'Home', end: true },
{ to: '/studio', label: 'Studio', end: false },
{ to: '/events', label: 'Events', end: false },
{ to: '/forum', label: 'Forum', end: false },
{ to: '/bugs', label: 'Bugs', end: false },
];

View File

@@ -8,6 +8,8 @@ import type {
BugReportNote,
StaffPost,
TeamMember,
EventPost,
Poll,
} from '../types';
// ── Mock Users ─────────────────────────────────────────────────────────────────
@@ -624,6 +626,123 @@ export const MOCK_STAFF_POSTS: StaffPost[] = [
},
];
// ── Mock Events & Polls ────────────────────────────────────────────────────────
export const MOCK_POLLS: Poll[] = [
{
id: 'poll1',
eventId: 'evt2',
question: 'Which feature should we prioritize for the next major update?',
options: [
{ id: 'opt1', text: 'New multiplayer maps', votes: 42, votedUserIds: ['u3', 'u4', 'u5', 'u7'] },
{ id: 'opt2', text: 'Co-op campaign mode', votes: 78, votedUserIds: ['u1', 'u2', 'u8'] },
{ id: 'opt3', text: 'Advanced physics system', votes: 35, votedUserIds: [] },
{ id: 'opt4', text: 'Character customization', votes: 51, votedUserIds: [] },
],
isActive: true,
endsAt: '2026-02-28T23:59:59Z',
allowMultipleVotes: false,
createdAt: '2026-02-16T10:00:00Z',
},
{
id: 'poll2',
eventId: 'evt5',
question: 'What type of content would you like to see more of in our devlogs?',
options: [
{ id: 'opt5', text: 'Behind-the-scenes coding', votes: 23, votedUserIds: [] },
{ id: 'opt6', text: 'Art process & concept art', votes: 45, votedUserIds: [] },
{ id: 'opt7', text: 'Level design breakdown', votes: 18, votedUserIds: [] },
{ id: 'opt8', text: 'Bug fix explanations', votes: 12, votedUserIds: [] },
],
isActive: false,
endsAt: '2026-02-10T23:59:59Z',
allowMultipleVotes: true,
createdAt: '2026-02-01T09:00:00Z',
},
];
export const MOCK_EVENTS: EventPost[] = [
{
id: 'evt1',
type: 'milestone',
title: 'Version 0.9.5 Released!',
content: 'We are excited to announce version 0.9.5 is now live! This update includes major performance improvements, the new "Factory District" map, and over 30 bug fixes. Check the changelog for full details. Thank you to everyone who participated in testing!',
authorId: 'u1',
authorName: 'Kestrel',
authorRole: 'dev',
createdAt: '2026-02-17T14:00:00Z',
isPublic: true,
},
{
id: 'evt2',
type: 'poll',
title: 'Community Poll: Next Feature Priority',
content: 'Help us decide what to work on next! We want to hear from you about which feature would enhance your experience the most. Vote below and feel free to discuss in the forum.',
authorId: 'u2',
authorName: 'Vesper',
authorRole: 'com',
createdAt: '2026-02-16T10:00:00Z',
isPublic: true,
pollId: 'poll1',
},
{
id: 'evt3',
type: 'announcement',
title: 'Server Maintenance Scheduled',
content: 'We will be performing server maintenance on February 20th from 2:00 AM to 6:00 AM UTC. Multiplayer services will be unavailable during this time. Single-player mode will remain accessible. We apologize for any inconvenience!',
authorId: 'u8',
authorName: 'ByteWitch',
authorRole: 'dev',
createdAt: '2026-02-15T16:30:00Z',
isPublic: true,
},
{
id: 'evt4',
type: 'update',
title: 'Co-op Mode Development Progress',
content: 'Quick update on co-op mode development: networking code is 80% complete, and we\'ve successfully tested 4-player sessions internally. Still working on some sync issues with physics objects, but overall progress is excellent. Aiming for beta testing in March!',
authorId: 'u1',
authorName: 'Kestrel',
authorRole: 'dev',
createdAt: '2026-02-14T11:20:00Z',
isPublic: true,
},
{
id: 'evt5',
type: 'poll',
title: 'Devlog Content Poll',
content: 'We want to make our devlogs more interesting for you! Let us know what kind of behind-the-scenes content you\'d like to see more of. You can vote for multiple options.',
authorId: 'u2',
authorName: 'Vesper',
authorRole: 'com',
createdAt: '2026-02-01T09:00:00Z',
isPublic: true,
pollId: 'poll2',
},
{
id: 'evt6',
type: 'announcement',
title: 'Community AMA This Wednesday',
content: 'Join us for a live AMA (Ask Me Anything) session this Wednesday at 7:00 PM UTC! The dev team will be answering questions about the game, upcoming features, and the development process. Post your questions in the forum thread beforehand or ask live during the session.',
authorId: 'u2',
authorName: 'Vesper',
authorRole: 'com',
createdAt: '2026-02-12T14:00:00Z',
isPublic: true,
},
{
id: 'evt7',
type: 'update',
title: 'New Character Model Work in Progress',
content: 'Our art team has been working on updated character models with more detailed textures while maintaining the retro aesthetic. Early tests look fantastic! We\'ll share some screenshots next week. This won\'t affect performance - we\'re being very careful about optimization.',
authorId: 'u1',
authorName: 'Kestrel',
authorRole: 'dev',
createdAt: '2026-02-08T10:15:00Z',
isPublic: true,
},
];
// ── Team Members ───────────────────────────────────────────────────────────────
export const TEAM_MEMBERS: TeamMember[] = [

View File

@@ -0,0 +1,722 @@
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 ROLE_COLORS: Record<UserRole, string> = {
dev: 'var(--color-green)',
com: 'var(--color-amber)',
user: 'var(--color-text-muted)',
};
const EVENT_TYPE_LABELS: Record<EventType, string> = {
announcement: 'ANNOUNCEMENT',
update: 'DEV UPDATE',
milestone: 'MILESTONE',
poll: 'COMMUNITY POLL',
};
// ── 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',
}}
>
{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 ? 'pointer' : 'default',
opacity: isEnded || !poll.isActive ? 0.7 : 1,
}}
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',
}}
>
<span>
{totalVotes} total votes
{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>
</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.25rem',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '1rem',
marginBottom: '0.75rem',
flexWrap: 'wrap',
}}
>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.4rem' }}>
<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.15rem 0.4rem',
letterSpacing: '0.08em',
}}
>
{EVENT_TYPE_LABELS[event.type]}
</span>
{event.isPublic && (
<span
style={{
fontFamily: 'var(--font-mono)',
background: 'rgba(34,197,94,0.1)',
border: '1px solid rgba(34,197,94,0.25)',
color: 'var(--color-green)',
fontSize: '0.6rem',
padding: '0.15rem 0.4rem',
letterSpacing: '0.08em',
}}
>
PUBLIC
</span>
)}
</div>
<h3
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: '1.1rem',
marginBottom: '0.25rem',
}}
>
{event.title}
</h3>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.68rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
}}
>
<span style={{ color: ROLE_COLORS[event.authorRole] }}>
{event.authorName}
</span>
<span></span>
<span>{formatDateTime(event.createdAt)}</span>
</div>
</div>
</div>
{/* Content */}
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: '0.85rem',
lineHeight: 1.75,
whiteSpace: 'pre-wrap',
}}
>
{event.content}
</div>
{/* Poll if exists */}
{poll && <PollCard poll={poll} onVote={onVote} />}
</div>
);
}
// ── Main Component ─────────────────────────────────────────────────────────────
export default function IntranetEvents() {
const { user } = useAuth();
const [events, setEvents] = useState<EventPost[]>(MOCK_EVENTS);
const [polls, setPolls] = useState<Poll[]>(MOCK_POLLS);
const [showCreateForm, setShowCreateForm] = useState(false);
// Form state
const [eventType, setEventType] = useState<EventType>('announcement');
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [isPublic, setIsPublic] = useState(true);
const [createPoll, setCreatePoll] = useState(false);
const [pollQuestion, setPollQuestion] = useState('');
const [pollOptions, setPollOptions] = useState<string[]>(['', '']);
const [error, setError] = useState('');
const [posting, setPosting] = useState(false);
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]
);
const handleSubmit = useCallback(async () => {
// Validation
if (!title.trim()) {
setError('Title is required.');
return;
}
if (!content.trim()) {
setError('Content is required.');
return;
}
if (createPoll) {
if (!pollQuestion.trim()) {
setError('Poll question is required.');
return;
}
const validOptions = pollOptions.filter((opt) => opt.trim());
if (validOptions.length < 2) {
setError('Poll must have at least 2 options.');
return;
}
}
if (!user) return;
setError('');
setPosting(true);
await new Promise((r) => setTimeout(r, 300));
const newEventId = `evt${Date.now()}`;
let newPollId: string | undefined;
// Create poll if needed
if (createPoll) {
newPollId = `poll${Date.now()}`;
const validOptions = pollOptions.filter((opt) => opt.trim());
const newPoll: Poll = {
id: newPollId,
eventId: newEventId,
question: pollQuestion.trim(),
options: validOptions.map((opt, idx) => ({
id: `opt${Date.now()}_${idx}`,
text: opt.trim(),
votes: 0,
votedUserIds: [],
})),
isActive: true,
allowMultipleVotes: false,
createdAt: new Date().toISOString(),
};
setPolls((prev) => [newPoll, ...prev]);
}
// Create event
const newEvent: EventPost = {
id: newEventId,
type: createPoll ? 'poll' : eventType,
title: title.trim(),
content: content.trim(),
authorId: user.id,
authorName: user.username,
authorRole: user.role,
createdAt: new Date().toISOString(),
isPublic,
pollId: newPollId,
};
setEvents((prev) => [newEvent, ...prev]);
// Reset form
setTitle('');
setContent('');
setEventType('announcement');
setIsPublic(true);
setCreatePoll(false);
setPollQuestion('');
setPollOptions(['', '']);
setPosting(false);
setShowCreateForm(false);
}, [title, content, eventType, isPublic, createPoll, pollQuestion, pollOptions, user]);
return (
<div style={{ maxWidth: '800px' }}>
<div style={{ marginBottom: '2rem' }}>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.7rem',
letterSpacing: '0.15em',
marginBottom: '0.5rem',
}}
>
INTRANET / EVENTS
</div>
<h1
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: '1.8rem',
}}
>
COMMUNITY EVENTS
</h1>
<p
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.78rem',
marginTop: '0.4rem',
}}
>
Post game development updates, announcements, and community polls. Public events are
visible to all users.
</p>
</div>
{/* Create Event Button */}
{!showCreateForm && (
<button
className="btn-terminal btn-amber"
onClick={() => setShowCreateForm(true)}
style={{ marginBottom: '1.5rem' }}
>
+ Create New Event
</button>
)}
{/* Create Event Form */}
{showCreateForm && (
<div
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
padding: '1.25rem',
marginBottom: '1.5rem',
}}
>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-amber)',
fontSize: '0.7rem',
letterSpacing: '0.1em',
marginBottom: '1rem',
}}
>
CREATE NEW EVENT
</div>
{/* Event Type */}
<div style={{ marginBottom: '1rem' }}>
<label
style={{
display: 'block',
fontFamily: 'var(--font-mono)',
fontSize: '0.7rem',
color: 'var(--color-text-muted)',
marginBottom: '0.4rem',
}}
>
EVENT TYPE
</label>
<select
className="input-terminal"
value={eventType}
onChange={(e) => setEventType(e.target.value as EventType)}
style={{ fontSize: '0.8rem' }}
disabled={createPoll}
>
<option value="announcement">Announcement</option>
<option value="update">Development Update</option>
<option value="milestone">Milestone</option>
</select>
</div>
{/* Title */}
<div style={{ marginBottom: '1rem' }}>
<label
style={{
display: 'block',
fontFamily: 'var(--font-mono)',
fontSize: '0.7rem',
color: 'var(--color-text-muted)',
marginBottom: '0.4rem',
}}
>
TITLE
</label>
<input
type="text"
className="input-terminal"
placeholder="Event title..."
value={title}
onChange={(e) => setTitle(e.target.value)}
style={{ fontSize: '0.85rem' }}
/>
</div>
{/* Content */}
<div style={{ marginBottom: '1rem' }}>
<label
style={{
display: 'block',
fontFamily: 'var(--font-mono)',
fontSize: '0.7rem',
color: 'var(--color-text-muted)',
marginBottom: '0.4rem',
}}
>
CONTENT
</label>
<textarea
className="input-terminal"
rows={4}
placeholder="Event description and details..."
value={content}
onChange={(e) => setContent(e.target.value)}
style={{ resize: 'vertical', fontSize: '0.85rem' }}
/>
</div>
{/* Public Toggle */}
<div style={{ marginBottom: '1rem' }}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontFamily: 'var(--font-mono)',
fontSize: '0.75rem',
color: 'var(--color-text-dim)',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
style={{ cursor: 'pointer' }}
/>
Make event visible to public
</label>
</div>
{/* Poll Toggle */}
<div style={{ marginBottom: '1rem' }}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontFamily: 'var(--font-mono)',
fontSize: '0.75rem',
color: 'var(--color-text-dim)',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={createPoll}
onChange={(e) => setCreatePoll(e.target.checked)}
style={{ cursor: 'pointer' }}
/>
Include a community poll
</label>
</div>
{/* Poll Form */}
{createPoll && (
<div
style={{
background: 'var(--color-bg-alt)',
border: '1px solid var(--color-border)',
padding: '1rem',
marginBottom: '1rem',
}}
>
<div
style={{
fontFamily: 'var(--font-mono)',
fontSize: '0.7rem',
color: 'var(--color-amber)',
letterSpacing: '0.1em',
marginBottom: '0.75rem',
}}
>
POLL DETAILS
</div>
{/* Poll Question */}
<div style={{ marginBottom: '0.75rem' }}>
<label
style={{
display: 'block',
fontFamily: 'var(--font-mono)',
fontSize: '0.7rem',
color: 'var(--color-text-muted)',
marginBottom: '0.4rem',
}}
>
QUESTION
</label>
<input
type="text"
className="input-terminal"
placeholder="What do you want to ask?"
value={pollQuestion}
onChange={(e) => setPollQuestion(e.target.value)}
style={{ fontSize: '0.8rem' }}
/>
</div>
{/* Poll Options */}
<div style={{ marginBottom: '0.5rem' }}>
<label
style={{
display: 'block',
fontFamily: 'var(--font-mono)',
fontSize: '0.7rem',
color: 'var(--color-text-muted)',
marginBottom: '0.4rem',
}}
>
OPTIONS
</label>
{pollOptions.map((option, idx) => (
<div key={idx} style={{ marginBottom: '0.4rem', display: 'flex', gap: '0.5rem' }}>
<input
type="text"
className="input-terminal"
placeholder={`Option ${idx + 1}`}
value={option}
onChange={(e) => {
const newOptions = [...pollOptions];
newOptions[idx] = e.target.value;
setPollOptions(newOptions);
}}
style={{ fontSize: '0.8rem', flex: 1 }}
/>
{pollOptions.length > 2 && (
<button
className="btn-terminal"
onClick={() => {
setPollOptions(pollOptions.filter((_, i) => i !== idx));
}}
style={{ padding: '0.4rem 0.6rem', fontSize: '0.7rem' }}
>
×
</button>
)}
</div>
))}
{pollOptions.length < 6 && (
<button
className="btn-terminal"
onClick={() => setPollOptions([...pollOptions, ''])}
style={{ fontSize: '0.7rem', marginTop: '0.4rem' }}
>
+ Add Option
</button>
)}
</div>
</div>
)}
{/* Error */}
{error && (
<div
style={{
color: 'var(--color-red)',
fontFamily: 'var(--font-mono)',
fontSize: '0.72rem',
marginBottom: '0.75rem',
}}
>
{error}
</div>
)}
{/* Actions */}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className="btn-terminal btn-amber"
onClick={handleSubmit}
disabled={posting}
style={{ opacity: posting ? 0.6 : 1 }}
>
{posting ? 'Creating...' : '> Create Event'}
</button>
<button
className="btn-terminal"
onClick={() => {
setShowCreateForm(false);
setError('');
setTitle('');
setContent('');
setCreatePoll(false);
setPollQuestion('');
setPollOptions(['', '']);
}}
disabled={posting}
>
Cancel
</button>
</div>
</div>
)}
{/* Events List */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{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>
);
}

View File

@@ -0,0 +1,366 @@
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>
);
}

View File

@@ -118,6 +118,42 @@ export interface StaffPost {
createdAt: string;
}
// ── Events & Polls ─────────────────────────────────────────────────────────────
export type EventType = 'announcement' | 'update' | 'milestone' | 'poll';
export interface EventPost {
id: string;
type: EventType;
title: string;
content: string;
authorId: string;
authorName: string;
authorRole: UserRole;
createdAt: string;
updatedAt?: string;
isPublic: boolean; // whether visible to community
pollId?: string; // reference to poll if type is 'poll'
}
export interface PollOption {
id: string;
text: string;
votes: number;
votedUserIds: string[]; // track who voted for this option
}
export interface Poll {
id: string;
eventId: string;
question: string;
options: PollOption[];
isActive: boolean;
endsAt?: string; // ISO 8601
allowMultipleVotes: boolean;
createdAt: string;
}
// ── Team / Studio ──────────────────────────────────────────────────────────────
export interface TeamMember {