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;
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;
}

View File

@@ -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>
);

View File

@@ -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 {