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