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