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:
358
convex/polls.ts
Normal file
358
convex/polls.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
const pollOptionValidator = v.object({
|
||||
id: v.string(),
|
||||
text: v.string(),
|
||||
});
|
||||
|
||||
const pollDocValidator = v.object({
|
||||
_id: v.id("polls"),
|
||||
_creationTime: v.number(),
|
||||
channelId: v.id("channels"),
|
||||
createdBy: v.id("userProfiles"),
|
||||
question: v.string(),
|
||||
options: v.array(pollOptionValidator),
|
||||
allowMultiple: v.boolean(),
|
||||
disclosed: v.boolean(),
|
||||
closed: v.boolean(),
|
||||
closesAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
});
|
||||
|
||||
const pollReactionValidator = v.object({
|
||||
emoji: v.string(),
|
||||
count: v.number(),
|
||||
me: v.boolean(),
|
||||
});
|
||||
|
||||
const pollResultsValidator = v.object({
|
||||
poll: pollDocValidator,
|
||||
totals: v.record(v.string(), v.number()),
|
||||
totalVotes: v.number(),
|
||||
myVote: v.union(v.array(v.string()), v.null()),
|
||||
// Aggregated as an array — arrays keep emoji out of object field
|
||||
// names (Convex's return-value validator rejects non-ASCII fields,
|
||||
// same issue we hit on messages.list).
|
||||
reactions: v.array(pollReactionValidator),
|
||||
});
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
channelId: v.id("channels"),
|
||||
createdBy: v.id("userProfiles"),
|
||||
question: v.string(),
|
||||
options: v.array(pollOptionValidator),
|
||||
allowMultiple: v.boolean(),
|
||||
disclosed: v.boolean(),
|
||||
closesAt: v.optional(v.number()),
|
||||
},
|
||||
returns: v.id("polls"),
|
||||
handler: async (ctx, args) => {
|
||||
const question = args.question.trim();
|
||||
if (question.length === 0) {
|
||||
throw new Error("Poll question cannot be empty");
|
||||
}
|
||||
if (question.length > 500) {
|
||||
throw new Error("Poll question is too long");
|
||||
}
|
||||
|
||||
const cleanOptions = args.options
|
||||
.map((o) => ({ id: o.id, text: o.text.trim() }))
|
||||
.filter((o) => o.text.length > 0);
|
||||
|
||||
if (cleanOptions.length < 2) {
|
||||
throw new Error("Polls need at least 2 options");
|
||||
}
|
||||
if (cleanOptions.length > 20) {
|
||||
throw new Error("Polls support at most 20 options");
|
||||
}
|
||||
|
||||
// Enforce unique option ids so vote diffing is unambiguous.
|
||||
const seen = new Set<string>();
|
||||
for (const opt of cleanOptions) {
|
||||
if (seen.has(opt.id)) {
|
||||
throw new Error("Duplicate option id");
|
||||
}
|
||||
seen.add(opt.id);
|
||||
}
|
||||
|
||||
const pollId = await ctx.db.insert("polls", {
|
||||
channelId: args.channelId,
|
||||
createdBy: args.createdBy,
|
||||
question,
|
||||
options: cleanOptions,
|
||||
allowMultiple: args.allowMultiple,
|
||||
disclosed: args.disclosed,
|
||||
closed: false,
|
||||
closesAt: args.closesAt,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
return pollId;
|
||||
},
|
||||
});
|
||||
|
||||
export const vote = mutation({
|
||||
args: {
|
||||
pollId: v.id("polls"),
|
||||
userId: v.id("userProfiles"),
|
||||
optionIds: v.array(v.string()),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const poll = await ctx.db.get(args.pollId);
|
||||
if (!poll) throw new Error("Poll not found");
|
||||
if (poll.closed) throw new Error("Poll is closed");
|
||||
if (poll.closesAt && poll.closesAt < Date.now()) {
|
||||
throw new Error("Poll has expired");
|
||||
}
|
||||
|
||||
// Validate the submitted option ids exist on the poll.
|
||||
const validIds = new Set(poll.options.map((o) => o.id));
|
||||
for (const id of args.optionIds) {
|
||||
if (!validIds.has(id)) {
|
||||
throw new Error(`Unknown option id "${id}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.optionIds.length === 0) {
|
||||
throw new Error("Select at least one option");
|
||||
}
|
||||
if (!poll.allowMultiple && args.optionIds.length > 1) {
|
||||
throw new Error("This poll only allows one answer");
|
||||
}
|
||||
|
||||
// Upsert: one row per (pollId, userId).
|
||||
const existing = await ctx.db
|
||||
.query("pollVotes")
|
||||
.withIndex("by_poll_and_user", (q) =>
|
||||
q.eq("pollId", args.pollId).eq("userId", args.userId),
|
||||
)
|
||||
.unique();
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
optionIds: args.optionIds,
|
||||
votedAt: Date.now(),
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("pollVotes", {
|
||||
pollId: args.pollId,
|
||||
userId: args.userId,
|
||||
optionIds: args.optionIds,
|
||||
votedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const clearVote = mutation({
|
||||
args: {
|
||||
pollId: v.id("polls"),
|
||||
userId: v.id("userProfiles"),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("pollVotes")
|
||||
.withIndex("by_poll_and_user", (q) =>
|
||||
q.eq("pollId", args.pollId).eq("userId", args.userId),
|
||||
)
|
||||
.unique();
|
||||
if (existing) {
|
||||
await ctx.db.delete(existing._id);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const close = mutation({
|
||||
args: {
|
||||
pollId: v.id("polls"),
|
||||
userId: v.id("userProfiles"),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const poll = await ctx.db.get(args.pollId);
|
||||
if (!poll) throw new Error("Poll not found");
|
||||
if (poll.createdBy !== args.userId) {
|
||||
throw new Error("Only the poll creator can close it");
|
||||
}
|
||||
if (poll.closed) return null;
|
||||
await ctx.db.patch(args.pollId, { closed: true });
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const remove = mutation({
|
||||
args: {
|
||||
pollId: v.id("polls"),
|
||||
userId: v.id("userProfiles"),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const poll = await ctx.db.get(args.pollId);
|
||||
if (!poll) return null;
|
||||
if (poll.createdBy !== args.userId) {
|
||||
throw new Error("Only the poll creator can delete it");
|
||||
}
|
||||
const votes = await ctx.db
|
||||
.query("pollVotes")
|
||||
.withIndex("by_poll", (q) => q.eq("pollId", args.pollId))
|
||||
.collect();
|
||||
await Promise.all(votes.map((v) => ctx.db.delete(v._id)));
|
||||
const reactions = await ctx.db
|
||||
.query("pollReactions")
|
||||
.withIndex("by_poll", (q) => q.eq("pollId", args.pollId))
|
||||
.collect();
|
||||
await Promise.all(reactions.map((r) => ctx.db.delete(r._id)));
|
||||
await ctx.db.delete(args.pollId);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const listByChannel = query({
|
||||
args: {
|
||||
channelId: v.id("channels"),
|
||||
},
|
||||
returns: v.array(pollDocValidator),
|
||||
handler: async (ctx, args) => {
|
||||
const polls = await ctx.db
|
||||
.query("polls")
|
||||
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
|
||||
.order("desc")
|
||||
.collect();
|
||||
return polls;
|
||||
},
|
||||
});
|
||||
|
||||
export const get = query({
|
||||
args: {
|
||||
pollId: v.id("polls"),
|
||||
userId: v.optional(v.id("userProfiles")),
|
||||
},
|
||||
returns: v.union(pollResultsValidator, v.null()),
|
||||
handler: async (ctx, args) => {
|
||||
const poll = await ctx.db.get(args.pollId);
|
||||
if (!poll) return null;
|
||||
|
||||
const votes = await ctx.db
|
||||
.query("pollVotes")
|
||||
.withIndex("by_poll", (q) => q.eq("pollId", args.pollId))
|
||||
.collect();
|
||||
|
||||
// Tally totals per option. Voters that picked multiple options
|
||||
// each contribute a +1 to every option they picked.
|
||||
const totals: Record<string, number> = {};
|
||||
for (const opt of poll.options) {
|
||||
totals[opt.id] = 0;
|
||||
}
|
||||
for (const vote of votes) {
|
||||
for (const id of vote.optionIds) {
|
||||
if (totals[id] !== undefined) {
|
||||
totals[id] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let myVote: string[] | null = null;
|
||||
if (args.userId) {
|
||||
const mine = votes.find((v) => v.userId === args.userId);
|
||||
myVote = mine ? mine.optionIds : null;
|
||||
}
|
||||
|
||||
// Aggregate reactions into an array of {emoji, count, me} rows.
|
||||
// Using a Map avoids putting unicode surrogates into object field
|
||||
// names, which Convex's return-value validator would reject.
|
||||
const reactionDocs = await ctx.db
|
||||
.query("pollReactions")
|
||||
.withIndex("by_poll", (q) => q.eq("pollId", args.pollId))
|
||||
.collect();
|
||||
const reactionMap = new Map<string, { count: number; me: boolean }>();
|
||||
for (const r of reactionDocs) {
|
||||
let entry = reactionMap.get(r.emoji);
|
||||
if (!entry) {
|
||||
entry = { count: 0, me: false };
|
||||
reactionMap.set(r.emoji, entry);
|
||||
}
|
||||
entry.count++;
|
||||
if (args.userId && r.userId === args.userId) {
|
||||
entry.me = true;
|
||||
}
|
||||
}
|
||||
const reactions: Array<{ emoji: string; count: number; me: boolean }> = [];
|
||||
reactionMap.forEach((info, emoji) => {
|
||||
reactions.push({ emoji, count: info.count, me: info.me });
|
||||
});
|
||||
|
||||
return {
|
||||
poll,
|
||||
totals,
|
||||
totalVotes: votes.length,
|
||||
myVote,
|
||||
reactions,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Toggle-add a reaction on a poll. Idempotent per (pollId, userId,
|
||||
* emoji) — re-adding the same reaction is a no-op.
|
||||
*/
|
||||
export const addReaction = mutation({
|
||||
args: {
|
||||
pollId: v.id("polls"),
|
||||
userId: v.id("userProfiles"),
|
||||
emoji: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("pollReactions")
|
||||
.withIndex("by_poll_user_emoji", (q) =>
|
||||
q
|
||||
.eq("pollId", args.pollId)
|
||||
.eq("userId", args.userId)
|
||||
.eq("emoji", args.emoji),
|
||||
)
|
||||
.unique();
|
||||
if (!existing) {
|
||||
await ctx.db.insert("pollReactions", {
|
||||
pollId: args.pollId,
|
||||
userId: args.userId,
|
||||
emoji: args.emoji,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Remove a reaction on a poll. No-op if the user hasn't reacted
|
||||
* with that emoji.
|
||||
*/
|
||||
export const removeReaction = mutation({
|
||||
args: {
|
||||
pollId: v.id("polls"),
|
||||
userId: v.id("userProfiles"),
|
||||
emoji: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("pollReactions")
|
||||
.withIndex("by_poll_user_emoji", (q) =>
|
||||
q
|
||||
.eq("pollId", args.pollId)
|
||||
.eq("userId", args.userId)
|
||||
.eq("emoji", args.emoji),
|
||||
)
|
||||
.unique();
|
||||
if (existing) {
|
||||
await ctx.db.delete(existing._id);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user