diff --git a/nest-front/client/src/App.tsx b/nest-front/client/src/App.tsx index 2e9a397..46ddd48 100644 --- a/nest-front/client/src/App.tsx +++ b/nest-front/client/src/App.tsx @@ -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() { }> } /> } /> + } /> } /> } /> } /> @@ -67,6 +70,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/nest-front/client/src/components/layout/IntranetLayout.tsx b/nest-front/client/src/components/layout/IntranetLayout.tsx index 72d9b6c..52bf4a0 100644 --- a/nest-front/client/src/components/layout/IntranetLayout.tsx +++ b/nest-front/client/src/components/layout/IntranetLayout.tsx @@ -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 }, ]; diff --git a/nest-front/client/src/components/shared/Navbar.tsx b/nest-front/client/src/components/shared/Navbar.tsx index 3c8fbdc..aa1b3d8 100644 --- a/nest-front/client/src/components/shared/Navbar.tsx +++ b/nest-front/client/src/components/shared/Navbar.tsx @@ -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 }, ]; diff --git a/nest-front/client/src/data/mockData.ts b/nest-front/client/src/data/mockData.ts index 843829f..792d22f 100644 --- a/nest-front/client/src/data/mockData.ts +++ b/nest-front/client/src/data/mockData.ts @@ -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[] = [ diff --git a/nest-front/client/src/pages/intranet/IntranetEvents.tsx b/nest-front/client/src/pages/intranet/IntranetEvents.tsx new file mode 100644 index 0000000..5fa3a20 --- /dev/null +++ b/nest-front/client/src/pages/intranet/IntranetEvents.tsx @@ -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 = { + announcement: 'var(--color-yellow)', + update: 'var(--color-blue)', + milestone: 'var(--color-green)', + poll: 'var(--color-amber)', +}; + +const ROLE_COLORS: Record = { + dev: 'var(--color-green)', + com: 'var(--color-amber)', + user: 'var(--color-text-muted)', +}; + +const EVENT_TYPE_LABELS: Record = { + 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 ( +
+
+ {poll.question} +
+
+ {poll.options.map((option) => { + const percentage = totalVotes > 0 ? Math.round((option.votes / totalVotes) * 100) : 0; + const userVoted = option.votedUserIds.includes(user?.id || ''); + + return ( +
{ + if (!isEnded && poll.isActive && user) { + onVote(poll.id, option.id); + } + }} + > + {/* Progress bar */} +
+
+ + {userVoted && '✓ '} + {option.text} + + + {option.votes} ({percentage}%) + +
+
+ ); + })} +
+
+ + {totalVotes} total votes + {poll.allowMultipleVotes && ' • Multiple votes allowed'} + + {poll.endsAt && ( + + {isEnded ? 'Poll Ended' : `Ends ${formatDateTime(poll.endsAt)}`} + + )} +
+
+ ); +} + +// ── Event Card Component ─────────────────────────────────────────────────────── + +function EventCard({ + event, + poll, + onVote, +}: { + event: EventPost; + poll?: Poll; + onVote: (pollId: string, optionId: string) => void; +}) { + return ( +
+ {/* Header */} +
+
+
+ + {EVENT_TYPE_LABELS[event.type]} + + {event.isPublic && ( + + PUBLIC + + )} +
+

+ {event.title} +

+
+ + {event.authorName} + + + {formatDateTime(event.createdAt)} +
+
+
+ + {/* Content */} +
+ {event.content} +
+ + {/* Poll if exists */} + {poll && } +
+ ); +} + +// ── Main Component ───────────────────────────────────────────────────────────── + +export default function IntranetEvents() { + const { user } = useAuth(); + const [events, setEvents] = useState(MOCK_EVENTS); + const [polls, setPolls] = useState(MOCK_POLLS); + const [showCreateForm, setShowCreateForm] = useState(false); + + // Form state + const [eventType, setEventType] = useState('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(['', '']); + 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 ( +
+
+
+ INTRANET / EVENTS +
+

+ COMMUNITY EVENTS +

+

+ Post game development updates, announcements, and community polls. Public events are + visible to all users. +

+
+ + {/* Create Event Button */} + {!showCreateForm && ( + + )} + + {/* Create Event Form */} + {showCreateForm && ( +
+
+ CREATE NEW EVENT +
+ + {/* Event Type */} +
+ + +
+ + {/* Title */} +
+ + setTitle(e.target.value)} + style={{ fontSize: '0.85rem' }} + /> +
+ + {/* Content */} +
+ +