feat: update Nginx configuration for API routing; implement API fetch utility in IntranetEvents component

This commit is contained in:
Thibault Pouch
2026-03-03 10:24:41 +01:00
parent db647fe7ac
commit 64fe3d440e
3 changed files with 87 additions and 112 deletions

View File

@@ -3,8 +3,12 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Use Docker's embedded DNS resolver; defer resolution to request time
resolver 127.0.0.11 valid=30s;
location /api/ { 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 Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
} }

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth, getToken } from '../../contexts/AuthContext';
import { formatDateTime } from '../../utils/format'; import { formatDateTime } from '../../utils/format';
import type { EventPost, EventType, Poll, UserRole } from '../../types'; import type { EventPost, EventType, Poll, UserRole } from '../../types';
@@ -244,10 +244,27 @@ function EventCard({
// ── Main Component ───────────────────────────────────────────────────────────── // ── 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() { export default function IntranetEvents() {
const { user } = useAuth(); const { user } = useAuth();
const [events, setEvents] = useState<EventPost[]>([]); const [events, setEvents] = useState<EventPost[]>([]);
const [polls, setPolls] = useState<Poll[]>([]);
const [showCreateForm, setShowCreateForm] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false);
// Form state // Form state
@@ -261,113 +278,63 @@ export default function IntranetEvents() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [posting, setPosting] = useState(false); const [posting, setPosting] = useState(false);
useEffect(() => {
apiFetch<{ events: EventPost[] }>('/events?limit=50')
.then((res) => setEvents(res.events))
.catch(() => setEvents([]));
}, []);
const handleVote = useCallback( const handleVote = useCallback(
(pollId: string, optionId: string) => { async (pollId: string, optionId: string) => {
if (!user) return; const event = events.find((e) => e.pollId === pollId || e.poll?.id === pollId);
if (!event || !user) return;
setPolls((prevPolls) => try {
prevPolls.map((poll) => { const updated = await apiFetch<EventPost>(`/events/${event.id}/vote`, {
if (poll.id !== pollId) return poll; method: 'POST',
body: JSON.stringify({ optionIds: [optionId] }),
const hasVotedForOption = poll.options.some((opt) => });
opt.votedUserIds.includes(user.id) setEvents((prev) => prev.map((e) => (e.id === updated.id ? updated : e)));
); } catch {
// silently ignore
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] [events, user]
); );
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
// Validation if (!title.trim()) { setError('Title is required.'); return; }
if (!title.trim()) { if (!content.trim()) { setError('Content is required.'); return; }
setError('Title is required.');
return;
}
if (!content.trim()) {
setError('Content is required.');
return;
}
if (createPoll) { if (createPoll) {
if (!pollQuestion.trim()) { if (!pollQuestion.trim()) { setError('Poll question is required.'); return; }
setError('Poll question is required.');
return;
}
const validOptions = pollOptions.filter((opt) => opt.trim()); const validOptions = pollOptions.filter((opt) => opt.trim());
if (validOptions.length < 2) { if (validOptions.length < 2) { setError('Poll must have at least 2 options.'); return; }
setError('Poll must have at least 2 options.');
return;
}
} }
if (!user) return; if (!user) return;
setError(''); setError('');
setPosting(true); setPosting(true);
await new Promise((r) => setTimeout(r, 300));
const newEventId = `evt${Date.now()}`; try {
let newPollId: string | undefined; const body: Record<string, unknown> = {
// 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, type: createPoll ? 'poll' : eventType,
title: title.trim(), title: title.trim(),
content: content.trim(), content: content.trim(),
authorId: user.id,
authorName: user.username,
authorRole: user.role,
createdAt: new Date().toISOString(),
isPublic, isPublic,
pollId: newPollId,
}; };
setEvents((prev) => [newEvent, ...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 // Reset form
setTitle(''); setTitle('');
@@ -377,8 +344,12 @@ export default function IntranetEvents() {
setCreatePoll(false); setCreatePoll(false);
setPollQuestion(''); setPollQuestion('');
setPollOptions(['', '']); setPollOptions(['', '']);
setPosting(false);
setShowCreateForm(false); 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]); }, [title, content, eventType, isPublic, createPoll, pollQuestion, pollOptions, user]);
return ( return (
@@ -711,10 +682,9 @@ export default function IntranetEvents() {
{/* Events List */} {/* Events List */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{events.map((event) => { {events.map((event) => (
const poll = event.pollId ? polls.find((p) => p.id === event.pollId) : undefined; <EventCard key={event.id} event={event} poll={event.poll ?? undefined} onVote={handleVote} />
return <EventCard key={event.id} event={event} poll={poll} onVote={handleVote} />; ))}
})}
</div> </div>
</div> </div>
); );

View File

@@ -133,7 +133,8 @@ export interface EventPost {
createdAt: string; createdAt: string;
updatedAt?: string; updatedAt?: string;
isPublic: boolean; // whether visible to community 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 { export interface PollOption {