modified: client/pages/Activity.tsx
This commit is contained in:
parent
abdb8943d5
commit
dc9f7ea66e
1 changed files with 136 additions and 47 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useState, useCallback, useRef, useMemo, type MouseEvent } from "react";
|
import { useEffect, useState, useCallback, useRef, useMemo, type MouseEvent } from "react";
|
||||||
import { useDiscordActivity } from "@/contexts/DiscordActivityContext";
|
import { useDiscordActivity } from "@/contexts/DiscordActivityContext";
|
||||||
import LoadingScreen from "@/components/LoadingScreen";
|
import LoadingScreen from "@/components/LoadingScreen";
|
||||||
|
import { supabase } from "@/lib/supabase";
|
||||||
import {
|
import {
|
||||||
Heart,
|
Heart,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
|
@ -972,37 +973,81 @@ function PollsTab({ userId, username }: { userId?: string; username?: string })
|
||||||
const [newOptions, setNewOptions] = useState(['', '']);
|
const [newOptions, setNewOptions] = useState(['', '']);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchPolls = useCallback(async () => {
|
||||||
const fetchPolls = async () => {
|
try {
|
||||||
try {
|
const response = await fetch('/api/activity/polls');
|
||||||
const response = await fetch('/api/activity/polls');
|
if (response.ok) {
|
||||||
if (response.ok) {
|
const data = await response.json();
|
||||||
const data = await response.json();
|
const mapped = (data.data || data || []).map((p: any) => {
|
||||||
const mapped = (data.data || data || []).map((p: any) => ({
|
const voteCounts = p.vote_counts || {};
|
||||||
|
const options = (p.options || []).map((opt: any, i: number) => ({
|
||||||
|
id: opt.id || `opt-${i}`,
|
||||||
|
text: opt.text || opt.option_text || String(opt),
|
||||||
|
votes: voteCounts[i] || 0,
|
||||||
|
}));
|
||||||
|
const expiresAt = p.expires_at ? new Date(p.expires_at).getTime() : Date.now() + 24 * 60 * 60 * 1000;
|
||||||
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
question: p.question,
|
question: p.question,
|
||||||
options: (p.options || []).map((opt: any, i: number) => ({
|
options,
|
||||||
id: opt.id || `opt-${i}`,
|
|
||||||
text: opt.text || opt.option_text,
|
|
||||||
votes: opt.votes || opt.vote_count || 0,
|
|
||||||
})),
|
|
||||||
createdBy: p.creator_id || p.created_by || 'system',
|
createdBy: p.creator_id || p.created_by || 'system',
|
||||||
createdByName: p.creator_name || 'Anonymous',
|
createdByName: p.creator_name || 'Anonymous',
|
||||||
createdAt: new Date(p.created_at).getTime(),
|
createdAt: new Date(p.created_at || Date.now()).getTime(),
|
||||||
expiresAt: new Date(p.expires_at).getTime(),
|
expiresAt,
|
||||||
votedUsers: p.voted_users || [],
|
votedUsers: p.voted_users || [],
|
||||||
}));
|
} as Poll;
|
||||||
setPolls(mapped.filter((p: Poll) => p.expiresAt > Date.now()));
|
});
|
||||||
}
|
setPolls(mapped.filter((p: Poll) => p.expiresAt > Date.now()));
|
||||||
} catch {
|
|
||||||
setPolls([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
} catch {
|
||||||
fetchPolls();
|
setPolls([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPolls();
|
||||||
|
|
||||||
|
const channel = supabase.channel("activity-polls");
|
||||||
|
|
||||||
|
channel.on(
|
||||||
|
"postgres_changes",
|
||||||
|
{ event: "INSERT", schema: "public", table: "activity_polls" },
|
||||||
|
() => {
|
||||||
|
fetchPolls();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
channel.on(
|
||||||
|
"postgres_changes",
|
||||||
|
{ event: "INSERT", schema: "public", table: "activity_poll_votes" },
|
||||||
|
(payload) => {
|
||||||
|
const vote = payload.new as any;
|
||||||
|
if (!vote?.poll_id || vote.option_index === undefined) return;
|
||||||
|
setPolls((prev) => {
|
||||||
|
const exists = prev.some((p) => p.id === vote.poll_id);
|
||||||
|
if (!exists) return prev;
|
||||||
|
return prev.map((p) => {
|
||||||
|
if (p.id !== vote.poll_id) return p;
|
||||||
|
const options = p.options.map((opt, idx) =>
|
||||||
|
idx === vote.option_index ? { ...opt, votes: opt.votes + 1 } : opt,
|
||||||
|
);
|
||||||
|
const votedUsers = p.votedUsers.includes(vote.user_id)
|
||||||
|
? p.votedUsers
|
||||||
|
: [...p.votedUsers, vote.user_id];
|
||||||
|
return { ...p, options, votedUsers };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
channel.subscribe().catch(() => {});
|
||||||
|
return () => {
|
||||||
|
supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}, [fetchPolls]);
|
||||||
|
|
||||||
const createPoll = async () => {
|
const createPoll = async () => {
|
||||||
if (!userId || !newQuestion.trim() || newOptions.filter(o => o.trim()).length < 2) return;
|
if (!userId || !newQuestion.trim() || newOptions.filter(o => o.trim()).length < 2) return;
|
||||||
|
|
||||||
|
|
@ -1050,6 +1095,8 @@ function PollsTab({ userId, username }: { userId?: string; username?: string })
|
||||||
|
|
||||||
const poll = polls.find(p => p.id === pollId);
|
const poll = polls.find(p => p.id === pollId);
|
||||||
if (!poll || poll.votedUsers.includes(userId)) return;
|
if (!poll || poll.votedUsers.includes(userId)) return;
|
||||||
|
const optionIndex = poll.options.findIndex(opt => opt.id === optionId);
|
||||||
|
if (optionIndex === -1) return;
|
||||||
|
|
||||||
setPolls(prev => prev.map(p => {
|
setPolls(prev => prev.map(p => {
|
||||||
if (p.id !== pollId) return p;
|
if (p.id !== pollId) return p;
|
||||||
|
|
@ -1066,7 +1113,7 @@ function PollsTab({ userId, username }: { userId?: string; username?: string })
|
||||||
await fetch(`/api/activity/polls/${pollId}/vote`, {
|
await fetch(`/api/activity/polls/${pollId}/vote`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ user_id: userId, option_id: optionId }),
|
body: JSON.stringify({ user_id: userId, option_index: optionIndex }),
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
@ -2548,35 +2595,75 @@ function ChatTab({
|
||||||
}) {
|
}) {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchMessages = useCallback(async () => {
|
||||||
const fetchMessages = async () => {
|
if (!hasLoaded) setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/activity/chat');
|
const response = await fetch('/api/activity/chat');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const mapped = (data.data || data || []).map((m: any) => ({
|
const mapped = (data.data || data || []).map((m: any) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
userId: m.user_id || m.userId,
|
userId: m.user_id || m.userId,
|
||||||
username: m.username || m.user_name || 'Anonymous',
|
username: m.username || m.user_name || 'Anonymous',
|
||||||
avatar: m.avatar_url || m.avatar || null,
|
avatar: m.avatar_url || m.avatar || null,
|
||||||
content: m.content || m.message,
|
content: m.content || m.message,
|
||||||
timestamp: new Date(m.created_at || m.timestamp).getTime(),
|
timestamp: new Date(m.created_at || m.timestamp).getTime(),
|
||||||
}));
|
}));
|
||||||
setMessages(mapped);
|
setMessages(mapped);
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setMessages([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
} catch {
|
||||||
|
setMessages([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setHasLoaded(true);
|
||||||
|
}
|
||||||
|
}, [hasLoaded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
fetchMessages();
|
fetchMessages();
|
||||||
}, []);
|
const interval = setInterval(fetchMessages, 5000);
|
||||||
|
const channel = supabase.channel("activity-chat");
|
||||||
|
|
||||||
|
channel.on(
|
||||||
|
"postgres_changes",
|
||||||
|
{
|
||||||
|
event: "INSERT",
|
||||||
|
schema: "public",
|
||||||
|
table: "activity_chat_messages",
|
||||||
|
},
|
||||||
|
(payload) => {
|
||||||
|
const m: any = payload.new || {};
|
||||||
|
const incoming: ChatMessage = {
|
||||||
|
id: m.id || `${Date.now()}`,
|
||||||
|
userId: m.user_id || m.userId,
|
||||||
|
username: m.username || m.user_name || "Anonymous",
|
||||||
|
avatar: m.avatar_url || m.avatar || null,
|
||||||
|
content: m.content || m.message,
|
||||||
|
timestamp: new Date(m.created_at || m.timestamp || Date.now()).getTime(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => {
|
||||||
|
if (prev.some((p) => p.id === incoming.id)) return prev;
|
||||||
|
const next = [...prev, incoming].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
return next.slice(-200);
|
||||||
|
});
|
||||||
|
setHasLoaded(true);
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
channel.subscribe().catch(() => {});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}, [fetchMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
|
@ -2609,11 +2696,13 @@ function ChatTab({
|
||||||
content: inputValue.trim(),
|
content: inputValue.trim(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
// Refresh from server to keep order aligned
|
||||||
|
await fetchMessages();
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
setSending(false);
|
setSending(false);
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, [inputValue, userId, username, avatar, sending]);
|
}, [inputValue, userId, username, avatar, sending, fetchMessages]);
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue