a tool for shared writing and social publishing
at feature/thread-viewer 501 lines 16 kB view raw
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&apos;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};