feat: update Nginx configuration for API routing; implement API fetch utility in IntranetEvents component
This commit is contained in:
@@ -3,8 +3,12 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Use Docker's embedded DNS resolver; defer resolution to request time
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:3000/api/;
|
||||
set $api_upstream http://api:3000;
|
||||
proxy_pass $api_upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
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';
|
||||
|
||||
@@ -244,10 +244,27 @@ function EventCard({
|
||||
|
||||
// ── 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 [polls, setPolls] = useState<Poll[]>([]);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
// Form state
|
||||
@@ -261,124 +278,78 @@ export default function IntranetEvents() {
|
||||
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(
|
||||
(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;
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
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
|
||||
}
|
||||
},
|
||||
[user]
|
||||
[events, user]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
// Validation
|
||||
if (!title.trim()) {
|
||||
setError('Title is required.');
|
||||
return;
|
||||
}
|
||||
if (!content.trim()) {
|
||||
setError('Content is required.');
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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 (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(),
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
type: createPoll ? 'poll' : eventType,
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
isPublic,
|
||||
};
|
||||
setPolls((prev) => [newPoll, ...prev]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
@@ -711,10 +682,9 @@ export default function IntranetEvents() {
|
||||
|
||||
{/* 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} />;
|
||||
})}
|
||||
{events.map((event) => (
|
||||
<EventCard key={event.id} event={event} poll={event.poll ?? undefined} onVote={handleVote} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -133,7 +133,8 @@ export interface EventPost {
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
isPublic: boolean; // whether visible to community
|
||||
pollId?: string; // reference to poll if type is 'poll'
|
||||
pollId?: string | null; // reference to poll if type is 'poll'
|
||||
poll?: Poll | null; // embedded poll data from API
|
||||
}
|
||||
|
||||
export interface PollOption {
|
||||
|
||||
Reference in New Issue
Block a user