a tool for shared writing and social publishing
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

clean up pollblock components add multi-vote

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