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