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&apos;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};