692 lines
22 KiB
TypeScript
692 lines
22 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
||
import { useAuth, getToken } 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 ─────────────────────────────────────────────────────────────
|
||
|
||
function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||
const token = getToken();
|
||
return fetch(`/api${path}`, {
|
||
...options,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
...(options.headers as Record<string, string> ?? {}),
|
||
},
|
||
}).then(async (res) => {
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({}));
|
||
throw new Error(body.error ?? `Request failed (${res.status})`);
|
||
}
|
||
return res.json() as Promise<T>;
|
||
});
|
||
}
|
||
|
||
export default function IntranetEvents() {
|
||
const { user } = useAuth();
|
||
const [events, setEvents] = useState<EventPost[]>([]);
|
||
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);
|
||
|
||
useEffect(() => {
|
||
apiFetch<{ events: EventPost[] }>('/events?limit=50')
|
||
.then((res) => setEvents(res.events))
|
||
.catch(() => setEvents([]));
|
||
}, []);
|
||
|
||
const handleVote = useCallback(
|
||
async (pollId: string, optionId: string) => {
|
||
const event = events.find((e) => e.pollId === pollId || e.poll?.id === pollId);
|
||
if (!event || !user) return;
|
||
try {
|
||
const updated = await apiFetch<EventPost>(`/events/${event.id}/vote`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ optionIds: [optionId] }),
|
||
});
|
||
setEvents((prev) => prev.map((e) => (e.id === updated.id ? updated : e)));
|
||
} catch {
|
||
// silently ignore
|
||
}
|
||
},
|
||
[events, user]
|
||
);
|
||
|
||
const handleSubmit = useCallback(async () => {
|
||
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);
|
||
|
||
try {
|
||
const body: Record<string, unknown> = {
|
||
type: createPoll ? 'poll' : eventType,
|
||
title: title.trim(),
|
||
content: content.trim(),
|
||
isPublic,
|
||
};
|
||
|
||
if (createPoll) {
|
||
body.poll = {
|
||
question: pollQuestion.trim(),
|
||
options: pollOptions.filter((o) => o.trim()).map((o) => ({ text: o.trim() })),
|
||
};
|
||
}
|
||
|
||
const created = await apiFetch<EventPost>('/events', {
|
||
method: 'POST',
|
||
body: JSON.stringify(body),
|
||
});
|
||
|
||
setEvents((prev) => [created, ...prev]);
|
||
|
||
// Reset form
|
||
setTitle('');
|
||
setContent('');
|
||
setEventType('announcement');
|
||
setIsPublic(true);
|
||
setCreatePoll(false);
|
||
setPollQuestion('');
|
||
setPollOptions(['', '']);
|
||
setShowCreateForm(false);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to create event.');
|
||
} finally {
|
||
setPosting(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) => (
|
||
<EventCard key={event.id} event={event} poll={event.poll ?? undefined} onVote={handleVote} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|