feat: add events and polls functionality to intranet and public pages
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
722
nest-front/client/src/pages/intranet/IntranetEvents.tsx
Normal file
722
nest-front/client/src/pages/intranet/IntranetEvents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
366
nest-front/client/src/pages/public/EventsPage.tsx
Normal file
366
nest-front/client/src/pages/public/EventsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user