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