a tool for shared writing and social publishing
1import { useUIState } from "src/useUIState";
2import { BlockProps } from "./Block";
3import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
4import { useCallback, useEffect, useState } from "react";
5import { focusElement, Input } from "components/Input";
6import { Separator } from "components/Layout";
7import { useEntitySetContext } from "components/EntitySetProvider";
8import { theme } from "tailwind.config";
9import { useEntity, useReplicache } from "src/replicache";
10import { v7 } from "uuid";
11import { usePollData } from "components/PageSWRDataProvider";
12import { voteOnPoll } from "actions/pollActions";
13import { create } from "zustand";
14import { elementId } from "src/utils/elementId";
15import { CheckTiny } from "components/Icons/CheckTiny";
16import { CloseTiny } from "components/Icons/CloseTiny";
17
18export let usePollBlockUIState = create(
19 () =>
20 ({}) as {
21 [entity: string]: { state: "editing" | "voting" | "results" } | undefined;
22 },
23);
24export const PollBlock = (props: BlockProps) => {
25 let isSelected = useUIState((s) =>
26 s.selectedBlocks.find((b) => b.value === props.entityID),
27 );
28 let { permissions } = useEntitySetContext();
29
30 let { data: pollData } = usePollData();
31 let hasVoted =
32 pollData?.voter_token &&
33 pollData.polls.find(
34 (v) =>
35 v.poll_votes_on_entity.voter_token === pollData.voter_token &&
36 v.poll_votes_on_entity.poll_entity === props.entityID,
37 );
38
39 let pollState = usePollBlockUIState((s) => s[props.entityID]?.state);
40 if (!pollState) {
41 if (hasVoted) pollState = "results";
42 else pollState = "voting";
43 }
44
45 const setPollState = useCallback(
46 (state: "editing" | "voting" | "results") => {
47 usePollBlockUIState.setState((s) => ({ [props.entityID]: { state } }));
48 },
49 [],
50 );
51
52 let votes =
53 pollData?.polls.filter(
54 (v) => v.poll_votes_on_entity.poll_entity === props.entityID,
55 ) || [];
56 let totalVotes = votes.length;
57
58 return (
59 <div
60 className={`poll flex flex-col gap-2 p-3 w-full
61 ${isSelected ? "block-border-selected " : "block-border"}`}
62 style={{
63 backgroundColor:
64 "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)",
65 }}
66 >
67 {pollState === "editing" ? (
68 <EditPoll
69 totalVotes={totalVotes}
70 votes={votes.map((v) => v.poll_votes_on_entity)}
71 entityID={props.entityID}
72 close={() => {
73 if (hasVoted) setPollState("results");
74 else setPollState("voting");
75 }}
76 />
77 ) : pollState === "results" ? (
78 <PollResults
79 entityID={props.entityID}
80 pollState={pollState}
81 setPollState={setPollState}
82 hasVoted={!!hasVoted}
83 />
84 ) : (
85 <PollVote
86 entityID={props.entityID}
87 onSubmit={() => setPollState("results")}
88 pollState={pollState}
89 setPollState={setPollState}
90 hasVoted={!!hasVoted}
91 />
92 )}
93 </div>
94 );
95};
96
97const PollVote = (props: {
98 entityID: string;
99 onSubmit: () => void;
100 pollState: "editing" | "voting" | "results";
101 setPollState: (pollState: "editing" | "voting" | "results") => void;
102 hasVoted: boolean;
103}) => {
104 let { data, mutate } = usePollData();
105 let { permissions } = useEntitySetContext();
106
107 let pollOptions = useEntity(props.entityID, "poll/options");
108 let currentVotes = data?.voter_token
109 ? data.polls
110 .filter(
111 (p) =>
112 p.poll_votes_on_entity.poll_entity === props.entityID &&
113 p.poll_votes_on_entity.voter_token === data.voter_token,
114 )
115 .map((v) => v.poll_votes_on_entity.option_entity)
116 : [];
117 let [selectedPollOptions, setSelectedPollOptions] =
118 useState<string[]>(currentVotes);
119
120 return (
121 <>
122 {pollOptions.map((option, index) => (
123 <PollVoteButton
124 key={option.data.value}
125 selected={selectedPollOptions.includes(option.data.value)}
126 toggleSelected={() =>
127 setSelectedPollOptions((s) =>
128 s.includes(option.data.value)
129 ? s.filter((s) => s !== option.data.value)
130 : [...s, option.data.value],
131 )
132 }
133 entityID={option.data.value}
134 />
135 ))}
136 <div className="flex justify-between items-center">
137 <div className="flex justify-end gap-2">
138 {permissions.write && (
139 <button
140 className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
141 onClick={() => {
142 props.setPollState("editing");
143 }}
144 >
145 Edit Options
146 </button>
147 )}
148
149 {permissions.write && <Separator classname="h-6" />}
150 <PollStateToggle
151 setPollState={props.setPollState}
152 pollState={props.pollState}
153 hasVoted={props.hasVoted}
154 />
155 </div>
156 <ButtonPrimary
157 className="place-self-end"
158 onClick={async () => {
159 await voteOnPoll(props.entityID, selectedPollOptions);
160 mutate((oldState) => {
161 if (!oldState || !oldState.voter_token) return;
162 return {
163 ...oldState,
164 polls: [
165 ...oldState.polls.filter(
166 (p) =>
167 !(
168 p.poll_votes_on_entity.voter_token ===
169 oldState.voter_token &&
170 p.poll_votes_on_entity.poll_entity == props.entityID
171 ),
172 ),
173 ...selectedPollOptions.map((option_entity) => ({
174 poll_votes_on_entity: {
175 option_entity,
176 entities: { set: "" },
177 poll_entity: props.entityID,
178 voter_token: oldState.voter_token!,
179 },
180 })),
181 ],
182 };
183 });
184 props.onSubmit();
185 }}
186 disabled={
187 selectedPollOptions.length === 0 ||
188 (selectedPollOptions.length === currentVotes.length &&
189 selectedPollOptions.every((s) => currentVotes.includes(s)))
190 }
191 >
192 Vote!
193 </ButtonPrimary>
194 </div>
195 </>
196 );
197};
198const PollVoteButton = (props: {
199 entityID: string;
200 selected: boolean;
201 toggleSelected: () => void;
202}) => {
203 let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
204 if (!optionName) return null;
205 if (props.selected)
206 return (
207 <div className="flex gap-2 items-center">
208 <ButtonPrimary
209 className={`pollOption grow max-w-full flex`}
210 onClick={() => {
211 props.toggleSelected();
212 }}
213 >
214 {optionName}
215 </ButtonPrimary>
216 </div>
217 );
218 return (
219 <div className="flex gap-2 items-center">
220 <ButtonSecondary
221 className={`pollOption grow max-w-full flex`}
222 onClick={() => {
223 props.toggleSelected();
224 }}
225 >
226 {optionName}
227 </ButtonSecondary>
228 </div>
229 );
230};
231
232const PollResults = (props: {
233 entityID: string;
234 pollState: "editing" | "voting" | "results";
235 setPollState: (pollState: "editing" | "voting" | "results") => void;
236 hasVoted: boolean;
237}) => {
238 let { data } = usePollData();
239 let { permissions } = useEntitySetContext();
240 let pollOptions = useEntity(props.entityID, "poll/options");
241 let pollData = data?.pollVotes.find((p) => p.poll_entity === props.entityID);
242 let votesByOptions = pollData?.votesByOption || {};
243 let highestVotes = Math.max(...Object.values(votesByOptions));
244 let winningOptionEntities = Object.entries(votesByOptions).reduce<string[]>(
245 (winningEntities, [entity, votes]) => {
246 if (votes === highestVotes) winningEntities.push(entity);
247 return winningEntities;
248 },
249 [],
250 );
251 return (
252 <>
253 {pollOptions.map((p) => (
254 <PollResult
255 key={p.id}
256 winner={winningOptionEntities.includes(p.data.value)}
257 entityID={p.data.value}
258 totalVotes={pollData?.unique_votes || 0}
259 votes={pollData?.votesByOption[p.data.value] || 0}
260 />
261 ))}
262 <div className="flex gap-2">
263 {permissions.write && (
264 <button
265 className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
266 onClick={() => {
267 props.setPollState("editing");
268 }}
269 >
270 Edit Options
271 </button>
272 )}
273
274 {permissions.write && <Separator classname="h-6" />}
275 <PollStateToggle
276 setPollState={props.setPollState}
277 pollState={props.pollState}
278 hasVoted={props.hasVoted}
279 />
280 </div>
281 </>
282 );
283};
284
285const PollResult = (props: {
286 entityID: string;
287 votes: number;
288 totalVotes: number;
289 winner: boolean;
290}) => {
291 let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
292 return (
293 <div
294 className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`}
295 >
296 <div
297 style={{
298 WebkitTextStroke: `${props.winner ? "6px" : "6px"} ${theme.colors["bg-page"]}`,
299 paintOrder: "stroke fill",
300 }}
301 className={`pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10`}
302 >
303 <div className="grow max-w-full truncate">{optionName}</div>
304 <div>{props.votes}</div>
305 </div>
306 <div
307 className={`pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0`}
308 >
309 <div
310 className={`bg-accent-contrast rounded-[2px] m-0.5`}
311 style={{
312 maskImage: "var(--hatchSVG)",
313 maskRepeat: "repeat repeat",
314
315 ...(props.votes === 0
316 ? { width: "4px" }
317 : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }),
318 }}
319 />
320 <div />
321 </div>
322 </div>
323 );
324};
325
326const EditPoll = (props: {
327 votes: { option_entity: string }[];
328 totalVotes: number;
329 entityID: string;
330 close: () => void;
331}) => {
332 let pollOptions = useEntity(props.entityID, "poll/options");
333 let { rep } = useReplicache();
334 let permission_set = useEntitySetContext();
335 let [localPollOptionNames, setLocalPollOptionNames] = useState<{
336 [k: string]: string;
337 }>({});
338 return (
339 <>
340 {props.totalVotes > 0 && (
341 <div className="text-sm italic text-tertiary">
342 You can't edit options people already voted for!
343 </div>
344 )}
345
346 {pollOptions.length === 0 && (
347 <div className="text-center italic text-tertiary text-sm">
348 no options yet...
349 </div>
350 )}
351 {pollOptions.map((p) => (
352 <EditPollOption
353 key={p.id}
354 entityID={p.data.value}
355 pollEntity={props.entityID}
356 disabled={!!props.votes.find((v) => v.option_entity === p.data.value)}
357 localNameState={localPollOptionNames[p.data.value]}
358 setLocalNameState={setLocalPollOptionNames}
359 />
360 ))}
361
362 <button
363 className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
364 onClick={async () => {
365 let pollOptionEntity = v7();
366 await rep?.mutate.addPollOption({
367 pollEntity: props.entityID,
368 pollOptionEntity,
369 pollOptionName: "",
370 permission_set: permission_set.set,
371 factID: v7(),
372 });
373
374 focusElement(
375 document.getElementById(
376 elementId.block(props.entityID).pollInput(pollOptionEntity),
377 ) as HTMLInputElement | null,
378 );
379 }}
380 >
381 Add an Option
382 </button>
383
384 <hr className="border-border" />
385 <ButtonPrimary
386 className="place-self-end"
387 onClick={async () => {
388 // remove any poll options that have no name
389 // look through the localPollOptionNames object and remove any options that have no name
390 let emptyOptions = Object.entries(localPollOptionNames).filter(
391 ([optionEntity, optionName]) => optionName === "",
392 );
393 await Promise.all(
394 emptyOptions.map(
395 async ([entity]) =>
396 await rep?.mutate.removePollOption({
397 optionEntity: entity,
398 }),
399 ),
400 );
401
402 await rep?.mutate.assertFact(
403 Object.entries(localPollOptionNames)
404 .filter(([, name]) => !!name)
405 .map(([entity, name]) => ({
406 entity,
407 attribute: "poll-option/name",
408 data: { type: "string", value: name },
409 })),
410 );
411 props.close();
412 }}
413 >
414 Save <CheckTiny />
415 </ButtonPrimary>
416 </>
417 );
418};
419
420const EditPollOption = (props: {
421 entityID: string;
422 pollEntity: string;
423 localNameState: string | undefined;
424 setLocalNameState: (
425 s: (s: { [k: string]: string }) => { [k: string]: string },
426 ) => void;
427 disabled: boolean;
428}) => {
429 let { rep } = useReplicache();
430 let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
431 useEffect(() => {
432 props.setLocalNameState((s) => ({
433 ...s,
434 [props.entityID]: optionName || "",
435 }));
436 }, [optionName, props.setLocalNameState, props.entityID]);
437
438 return (
439 <div className="flex gap-2 items-center">
440 <Input
441 id={elementId.block(props.pollEntity).pollInput(props.entityID)}
442 type="text"
443 className="pollOptionInput w-full input-with-border"
444 placeholder="Option here..."
445 disabled={props.disabled}
446 value={
447 props.localNameState === undefined ? optionName : props.localNameState
448 }
449 onChange={(e) => {
450 props.setLocalNameState((s) => ({
451 ...s,
452 [props.entityID]: e.target.value,
453 }));
454 }}
455 onKeyDown={(e) => {
456 if (e.key === "Backspace" && !e.currentTarget.value) {
457 e.preventDefault();
458 rep?.mutate.removePollOption({ optionEntity: props.entityID });
459 }
460 }}
461 />
462
463 <button
464 tabIndex={-1}
465 disabled={props.disabled}
466 className="text-accent-contrast disabled:text-border"
467 onMouseDown={async () => {
468 await rep?.mutate.removePollOption({ optionEntity: props.entityID });
469 }}
470 >
471 <CloseTiny />
472 </button>
473 </div>
474 );
475};
476
477const PollStateToggle = (props: {
478 setPollState: (pollState: "editing" | "voting" | "results") => void;
479 hasVoted: boolean;
480 pollState: "editing" | "voting" | "results";
481}) => {
482 return (
483 <button
484 className="text-sm text-accent-contrast sm:hover:underline"
485 onClick={() => {
486 props.setPollState(props.pollState === "voting" ? "results" : "voting");
487 }}
488 >
489 {props.pollState === "voting"
490 ? "See Results"
491 : props.hasVoted
492 ? "Change Vote"
493 : "Back to Poll"}
494 </button>
495 );
496};