feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- Implemented Button component with various props for customization. - Created Modal component with header, content, and footer subcomponents. - Added Spinner component for loading indicators. - Developed Toast component for displaying notifications. - Introduced Tooltip component for contextual hints with keyboard shortcuts. - Added corresponding CSS modules for styling each component. - Updated index file to export new components. - Configured TypeScript settings for the UI package.
This commit is contained in:
349
packages/shared/src/components/channel/PollCard.tsx
Normal file
349
packages/shared/src/components/channel/PollCard.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* PollCard — renders a Convex-backed poll inline in the message
|
||||
* timeline. Single- and multi-selection polls are distinguished by
|
||||
* `poll.allowMultiple` (radio vs checkbox). Disclosed polls show the
|
||||
* live tally; undisclosed polls hide counts until the poll is closed.
|
||||
*
|
||||
* Clicks send (or clear) the viewer's vote via `api.polls.vote` /
|
||||
* `api.polls.clearVote`. Because the parent `useQuery(api.polls.get)`
|
||||
* is reactive, the tally updates live for every viewer whenever
|
||||
* anyone votes.
|
||||
*/
|
||||
import { useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { Smiley } from '@phosphor-icons/react';
|
||||
import { Modal, Button } from '@discord-clone/ui';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import type { Id } from '../../../../../convex/_generated/dataModel';
|
||||
import { EmojiPicker, type EmojiPickerValue } from './EmojiPicker';
|
||||
import { TwemojiImg } from './TwemojiImg';
|
||||
import { resolveReactionKeyToUnicode } from '../../utils/emojiLookup';
|
||||
import styles from './PollCard.module.css';
|
||||
|
||||
interface PollCardProps {
|
||||
pollId: Id<'polls'>;
|
||||
}
|
||||
|
||||
export function PollCard({ pollId }: PollCardProps) {
|
||||
const myUserId =
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
|
||||
|
||||
const result = useQuery(api.polls.get, {
|
||||
pollId,
|
||||
userId: (myUserId ?? undefined) as Id<'userProfiles'> | undefined,
|
||||
});
|
||||
|
||||
const voteMutation = useMutation(api.polls.vote);
|
||||
const clearVoteMutation = useMutation(api.polls.clearVote);
|
||||
const closeMutation = useMutation(api.polls.close);
|
||||
const addReactionMutation = useMutation(api.polls.addReaction);
|
||||
const removeReactionMutation = useMutation(api.polls.removeReaction);
|
||||
|
||||
// Reaction picker — anchored off the Add Reaction button. `null`
|
||||
// means the picker is closed.
|
||||
const addReactionButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [reactPickerPos, setReactPickerPos] = useState<
|
||||
{ top: number; left: number } | null
|
||||
>(null);
|
||||
|
||||
const [endConfirmOpen, setEndConfirmOpen] = useState(false);
|
||||
const [ending, setEnding] = useState(false);
|
||||
const [endError, setEndError] = useState<string | null>(null);
|
||||
|
||||
if (!result) {
|
||||
return <div className={styles.card}>Loading poll…</div>;
|
||||
}
|
||||
|
||||
const { poll, totals, totalVotes, myVote, reactions } = result;
|
||||
const isEnded = poll.closed || (poll.closesAt != null && poll.closesAt < Date.now());
|
||||
const isMultiple = poll.allowMultiple;
|
||||
const countsVisible = poll.disclosed || isEnded;
|
||||
|
||||
const mySelectionSet = new Set<string>(myVote ?? []);
|
||||
|
||||
const handleToggleReaction = (emoji: string, me: boolean) => {
|
||||
if (!myUserId) return;
|
||||
if (me) {
|
||||
void removeReactionMutation({
|
||||
pollId: poll._id,
|
||||
userId: myUserId as Id<'userProfiles'>,
|
||||
emoji,
|
||||
});
|
||||
} else {
|
||||
void addReactionMutation({
|
||||
pollId: poll._id,
|
||||
userId: myUserId as Id<'userProfiles'>,
|
||||
emoji,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openReactPicker = () => {
|
||||
const btn = addReactionButtonRef.current;
|
||||
if (!btn) return;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
setReactPickerPos({
|
||||
// Anchor the picker so its bottom edge sits just above the
|
||||
// button — matches the message reaction picker placement.
|
||||
top: Math.max(8, rect.top - 440),
|
||||
left: Math.max(8, rect.left - 240),
|
||||
});
|
||||
};
|
||||
|
||||
const handlePickReaction = (value: EmojiPickerValue) => {
|
||||
if (!myUserId) return;
|
||||
// GIF picks are meaningless as reactions — silently drop them.
|
||||
if (value.kind === 'gif') {
|
||||
setReactPickerPos(null);
|
||||
return;
|
||||
}
|
||||
const emojiKey =
|
||||
value.kind === 'custom' ? value.shortcode : value.surrogates;
|
||||
// If the viewer already reacted with this emoji, toggle it off
|
||||
// instead of inserting a duplicate row (mutation is idempotent
|
||||
// either way, but this matches chat behaviour).
|
||||
const already = reactions?.some((r) => r.emoji === emojiKey && r.me);
|
||||
if (already) {
|
||||
void removeReactionMutation({
|
||||
pollId: poll._id,
|
||||
userId: myUserId as Id<'userProfiles'>,
|
||||
emoji: emojiKey,
|
||||
});
|
||||
} else {
|
||||
void addReactionMutation({
|
||||
pollId: poll._id,
|
||||
userId: myUserId as Id<'userProfiles'>,
|
||||
emoji: emojiKey,
|
||||
});
|
||||
}
|
||||
setReactPickerPos(null);
|
||||
};
|
||||
|
||||
const handleClick = (optionId: string) => {
|
||||
if (isEnded) return;
|
||||
if (!myUserId) return;
|
||||
|
||||
let next: string[];
|
||||
if (isMultiple) {
|
||||
const current = new Set(mySelectionSet);
|
||||
if (current.has(optionId)) current.delete(optionId);
|
||||
else current.add(optionId);
|
||||
next = Array.from(current);
|
||||
} else {
|
||||
next = mySelectionSet.has(optionId) ? [] : [optionId];
|
||||
}
|
||||
|
||||
if (next.length === 0) {
|
||||
void clearVoteMutation({
|
||||
pollId: poll._id,
|
||||
userId: myUserId as Id<'userProfiles'>,
|
||||
});
|
||||
} else {
|
||||
void voteMutation({
|
||||
pollId: poll._id,
|
||||
userId: myUserId as Id<'userProfiles'>,
|
||||
optionIds: next,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Bars scale to the leading answer so the winner reaches 100% and
|
||||
// the rest scale proportionally — matches the new UI's visuals.
|
||||
let maxCount = 0;
|
||||
for (const opt of poll.options) {
|
||||
const c = totals[opt.id] ?? 0;
|
||||
if (c > maxCount) maxCount = c;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.question}>{poll.question || 'Poll'}</div>
|
||||
|
||||
{!countsVisible && !isEnded && (
|
||||
<div className={styles.meta}>Results hidden until the poll ends</div>
|
||||
)}
|
||||
{isEnded && (
|
||||
<div className={`${styles.meta} ${styles.metaEnded}`}>Poll ended</div>
|
||||
)}
|
||||
|
||||
<div className={styles.answers}>
|
||||
{poll.options.map((option) => {
|
||||
const count = totals[option.id] ?? 0;
|
||||
const selected = mySelectionSet.has(option.id);
|
||||
const pct = !countsVisible
|
||||
? 0
|
||||
: maxCount === 0
|
||||
? 0
|
||||
: (count / maxCount) * 100;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={option.id}
|
||||
className={[
|
||||
styles.answer,
|
||||
selected ? styles.answerSelected : '',
|
||||
isEnded ? styles.answerDisabled : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{ ['--pct' as any]: `${pct}%` }}
|
||||
onClick={() => handleClick(option.id)}
|
||||
disabled={isEnded}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
styles.radio,
|
||||
isMultiple ? styles.radioCheckbox : '',
|
||||
selected ? styles.radioChecked : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
/>
|
||||
<span className={styles.label}>{option.text}</span>
|
||||
{countsVisible && (
|
||||
<span className={styles.count}>
|
||||
{count} {count === 1 ? 'vote' : 'votes'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.totalRow}>
|
||||
<span>
|
||||
{totalVotes} {totalVotes === 1 ? 'voter' : 'voters'}
|
||||
{isMultiple ? ' · multi-select' : ''}
|
||||
</span>
|
||||
{myUserId === poll.createdBy && !isEnded && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.endButton}
|
||||
onClick={() => {
|
||||
setEndError(null);
|
||||
setEndConfirmOpen(true);
|
||||
}}
|
||||
>
|
||||
End Poll
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(reactions.length > 0 || myUserId) && (
|
||||
<div className={styles.reactions}>
|
||||
{reactions.map((r) => (
|
||||
<button
|
||||
key={r.emoji}
|
||||
type="button"
|
||||
className={`${styles.reactionChip} ${r.me ? styles.reactionMe : ''}`}
|
||||
onClick={() => handleToggleReaction(r.emoji, r.me)}
|
||||
>
|
||||
<TwemojiImg
|
||||
emoji={resolveReactionKeyToUnicode(r.emoji)}
|
||||
size={16}
|
||||
/>
|
||||
<span className={styles.reactionCount}>{r.count}</span>
|
||||
</button>
|
||||
))}
|
||||
{myUserId && (
|
||||
<button
|
||||
ref={addReactionButtonRef}
|
||||
type="button"
|
||||
className={styles.addReactionChip}
|
||||
onClick={openReactPicker}
|
||||
aria-label="Add reaction"
|
||||
title="Add reaction"
|
||||
>
|
||||
<Smiley size={14} weight="regular" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reactPickerPos &&
|
||||
createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: reactPickerPos.top,
|
||||
left: reactPickerPos.left,
|
||||
zIndex: 15000,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EmojiPicker
|
||||
onSelect={handlePickReaction}
|
||||
onClose={() => setReactPickerPos(null)}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
<Modal.Root
|
||||
isOpen={endConfirmOpen}
|
||||
onClose={() => {
|
||||
if (ending) return;
|
||||
setEndConfirmOpen(false);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
<Modal.Header
|
||||
title="End poll?"
|
||||
onClose={() => {
|
||||
if (ending) return;
|
||||
setEndConfirmOpen(false);
|
||||
}}
|
||||
/>
|
||||
<Modal.Content>
|
||||
<div className={styles.endModalBody}>
|
||||
<p className={styles.endModalDescription}>
|
||||
Voting will be locked and the results will be finalised.
|
||||
{!poll.disclosed && (
|
||||
<> Hidden vote counts will become visible to everyone.</>
|
||||
)}
|
||||
</p>
|
||||
<div className={styles.endModalQuestion}>
|
||||
{poll.question || 'Poll'}
|
||||
</div>
|
||||
{endError && <div className={styles.endModalError}>{endError}</div>}
|
||||
<div className={styles.endModalActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setEndConfirmOpen(false)}
|
||||
disabled={ending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
loading={ending}
|
||||
onClick={async () => {
|
||||
if (!myUserId) return;
|
||||
setEnding(true);
|
||||
setEndError(null);
|
||||
try {
|
||||
await closeMutation({
|
||||
pollId: poll._id,
|
||||
userId: myUserId as Id<'userProfiles'>,
|
||||
});
|
||||
setEndConfirmOpen(false);
|
||||
} catch (err: any) {
|
||||
setEndError(err?.message || 'Failed to end the poll.');
|
||||
} finally {
|
||||
setEnding(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
End Poll
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user