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