a tool for shared writing and social publishing
at feature/backdate 498 lines 15 kB view raw
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&apos;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};