a8532fea21
- /poll command opens creation modal (2-10 options) - PollDisplay with vote bars, percentages, live WS updates - Backend: polls/poll_options/poll_votes tables, Create/Get/Vote endpoints - attachPolls enriches message list responses - POLL_UPDATE broadcast on vote for real-time sync
206 lines
6.4 KiB
TypeScript
206 lines
6.4 KiB
TypeScript
import { useState } from "react";
|
|
import { useMessageStore, type Poll } from "../stores/message.ts";
|
|
|
|
interface CreatePollModalProps {
|
|
channelId: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function CreatePollModal({ channelId, onClose }: CreatePollModalProps) {
|
|
const [question, setQuestion] = useState("");
|
|
const [options, setOptions] = useState(["", ""]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const createPoll = useMessageStore((s) => s.createPoll);
|
|
|
|
const addOption = () => {
|
|
if (options.length < 10) setOptions([...options, ""]);
|
|
};
|
|
|
|
const removeOption = (idx: number) => {
|
|
if (options.length > 2) setOptions(options.filter((_, i) => i !== idx));
|
|
};
|
|
|
|
const updateOption = (idx: number, value: string) => {
|
|
const next = [...options];
|
|
next[idx] = value;
|
|
setOptions(next);
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const filled = options.filter((o) => o.trim());
|
|
if (!question.trim() || filled.length < 2) {
|
|
setError("Need a question and at least 2 options");
|
|
return;
|
|
}
|
|
try {
|
|
await createPoll(channelId, question.trim(), filled.map((o) => o.trim()));
|
|
onClose();
|
|
} catch {
|
|
setError("Failed to create poll");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
className="bg-gb-bg border border-gb-bg-t p-4 w-[420px] max-h-[80vh] overflow-y-auto"
|
|
>
|
|
<h3 className="text-gb-orange font-mono text-sm mb-3 uppercase tracking-wide">
|
|
Create Poll
|
|
</h3>
|
|
|
|
<input
|
|
type="text"
|
|
value={question}
|
|
onChange={(e) => setQuestion(e.target.value)}
|
|
placeholder="Question..."
|
|
maxLength={300}
|
|
className="terminal-input w-full mb-3"
|
|
autoFocus
|
|
/>
|
|
|
|
<div className="space-y-2 mb-3">
|
|
{options.map((opt, i) => (
|
|
<div key={i} className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={opt}
|
|
onChange={(e) => updateOption(i, e.target.value)}
|
|
placeholder={`Option ${i + 1}`}
|
|
maxLength={150}
|
|
className="terminal-input flex-1"
|
|
/>
|
|
{options.length > 2 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => removeOption(i)}
|
|
className="text-gb-fg hover:text-gb-red px-2 font-mono"
|
|
>
|
|
x
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{options.length < 10 && (
|
|
<button
|
|
type="button"
|
|
onClick={addOption}
|
|
className="text-gb-green hover:text-gb-fg font-mono text-xs mb-3"
|
|
>
|
|
+ add option
|
|
</button>
|
|
)}
|
|
|
|
{error && <div className="text-gb-red text-xs font-mono mb-2">{error}</div>}
|
|
|
|
<div className="flex gap-2 justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-3 py-1 text-xs font-mono text-gb-fg hover:text-gb-fg"
|
|
>
|
|
cancel
|
|
</button>
|
|
<button type="submit" className="btn-primary px-3 py-1 text-xs font-mono">
|
|
create poll
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface PollDisplayProps {
|
|
poll: Poll;
|
|
channelId: string;
|
|
}
|
|
|
|
export function PollDisplay({ poll, channelId }: PollDisplayProps) {
|
|
const votePoll = useMessageStore((s) => s.votePoll);
|
|
const updatePoll = useMessageStore((s) => s.updatePoll);
|
|
const currentUserId = localStorage.getItem("userId") || "";
|
|
|
|
const totalVotes = poll.options.reduce((sum, o) => sum + o.votes, 0);
|
|
const userVotedOption = poll.options.find((o) =>
|
|
o.voters.includes(currentUserId),
|
|
);
|
|
|
|
const handleVote = async (optionId: string) => {
|
|
try {
|
|
await votePoll(poll.id, optionId);
|
|
// Optimistic: update local state
|
|
const updated = {
|
|
...poll,
|
|
options: poll.options.map((o) => {
|
|
const wasVoted = o.voters.includes(currentUserId);
|
|
const isTarget = o.id === optionId;
|
|
let voters = o.voters.filter((v) => v !== currentUserId);
|
|
let votes = o.votes;
|
|
if (wasVoted && o.id !== optionId) votes--;
|
|
if (isTarget) {
|
|
voters = [...voters, currentUserId];
|
|
votes = o.votes + (wasVoted ? 0 : 1);
|
|
}
|
|
return { ...o, votes, voters };
|
|
}),
|
|
};
|
|
updatePoll(channelId, updated);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="border border-gb-bg-t bg-gb-bg-s p-3 mt-1 max-w-[400px]">
|
|
<div className="text-gb-orange font-mono text-xs mb-2 uppercase tracking-wide">
|
|
Poll
|
|
</div>
|
|
<div className="text-gb-fg font-mono text-sm mb-3">{poll.question}</div>
|
|
|
|
<div className="space-y-2">
|
|
{poll.options.map((opt) => {
|
|
const pct = totalVotes > 0 ? Math.round((opt.votes / totalVotes) * 100) : 0;
|
|
const isSelected = opt.voters.includes(currentUserId);
|
|
|
|
return (
|
|
<button
|
|
key={opt.id}
|
|
type="button"
|
|
onClick={() => handleVote(opt.id)}
|
|
className="w-full text-left relative group"
|
|
>
|
|
<div className="relative flex items-center justify-between px-2 py-1.5 border border-gb-bg-t hover:border-gb-orange transition-colors">
|
|
{/* progress bar background */}
|
|
<div
|
|
className={`absolute inset-0 ${isSelected ? "bg-gb-green/15" : "bg-gb-bg-t/40"}`}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
<span className="relative font-mono text-xs text-gb-fg flex-1">
|
|
{isSelected && <span className="text-gb-green mr-1">{'>'}</span>}
|
|
{opt.text}
|
|
</span>
|
|
<span className="relative font-mono text-xs text-gb-fg-s ml-2">
|
|
{opt.votes} ({pct}%)
|
|
</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="text-gb-fg-f font-mono text-xs mt-2">
|
|
{totalVotes} vote{totalVotes !== 1 ? "s" : ""}
|
|
{userVotedOption && (
|
|
<span className="text-gb-green ml-2">
|
|
{'('}you voted: {userVotedOption.text}{')'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|