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