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;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user