a tool for shared writing and social publishing

added pollBlock

+276 -18
-15
app/globals.css
··· 229 229 @apply outline-transparent; 230 230 } 231 231 232 - .text-with-outline { 233 - position: relative; 234 - -webkit-text-stroke: 1px purple; 235 - z-index: 1; 236 - 237 - ::before { 238 - content: attr(data-text); 239 - position: absolute; 240 - top: 0; 241 - left: 0; 242 - color: blue; 243 - z-index: 0; 244 - } 245 - } 246 - 247 232 .pwa-padding { 248 233 padding-top: max(calc(env(safe-area-inset-top) - 8px)) !important; 249 234 }
+2 -1
components/Blocks/Block.tsx
··· 23 23 import { RSVPBlock } from "./RSVPBlock"; 24 24 import { elementId } from "src/utils/elementId"; 25 25 import { ButtonBlock } from "./ButtonBlock"; 26 + import { PollBlock } from "./PollBlock"; 26 27 27 28 export type Block = { 28 29 factID: string; ··· 71 72 ); 72 73 73 74 let [areYouSure, setAreYouSure] = useState(false); 74 - 75 75 useEffect(() => { 76 76 if (!selected) { 77 77 setAreYouSure(false); ··· 176 176 datetime: DateTimeBlock, 177 177 rsvp: RSVPBlock, 178 178 button: ButtonBlock, 179 + poll: PollBlock, 179 180 }; 180 181 181 182 export const BlockMultiselectIndicator = (props: BlockProps) => {
+9
components/Blocks/BlockCommands.tsx
··· 204 204 createBlockWithType(rep, props, "mailbox"); 205 205 }, 206 206 }, 207 + { 208 + name: "Poll", 209 + icon: <BlockMailboxSmall />, 210 + type: "block", 211 + onSelect: async (rep, props) => { 212 + let entity; 213 + createBlockWithType(rep, props, "poll"); 214 + }, 215 + }, 207 216 208 217 // EVENT STUFF 209 218
+262
components/Blocks/PollBlock.tsx
··· 1 + import { useUIState } from "src/useUIState"; 2 + import { BlockProps } from "./Block"; 3 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 4 + import { useEffect, useState } from "react"; 5 + import { Input } from "components/Input"; 6 + import { CheckTiny, CloseTiny, InfoSmall } from "components/Icons"; 7 + import { Separator } from "components/Layout"; 8 + import { useEntitySetContext } from "components/EntitySetProvider"; 9 + import { theme } from "tailwind.config"; 10 + 11 + export const PollBlock = (props: BlockProps) => { 12 + let isSelected = useUIState((s) => 13 + s.selectedBlocks.find((b) => b.value === props.entityID), 14 + ); 15 + let { permissions } = useEntitySetContext(); 16 + 17 + let [pollState, setPollState] = useState<"editing" | "voting" | "results">( 18 + !permissions.write ? "voting" : "editing", 19 + ); 20 + 21 + let [pollOptions, setPollOptions] = useState< 22 + { value: string; votes: number }[] 23 + >([ 24 + { value: "hello", votes: 2 }, 25 + { value: "hi", votes: 4 }, 26 + ]); 27 + 28 + let totalVotes = pollOptions.reduce((sum, option) => sum + option.votes, 0); 29 + 30 + let highestVotes = Math.max(...pollOptions.map((option) => option.votes)); 31 + let winningIndexes = pollOptions.reduce<number[]>( 32 + (indexes, option, index) => { 33 + if (option.votes === highestVotes) indexes.push(index); 34 + return indexes; 35 + }, 36 + [], 37 + ); 38 + 39 + return ( 40 + <div 41 + className={`poll flex flex-col gap-2 p-3 w-full 42 + ${isSelected ? "block-border-selected " : "block-border"}`} 43 + style={{ 44 + backgroundColor: 45 + "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 46 + }} 47 + > 48 + {pollState === "editing" && totalVotes > 0 && ( 49 + <div className="text-sm italic text-tertiary"> 50 + You can&apos;t edit options people already voted for! 51 + </div> 52 + )} 53 + 54 + {/* Empty state if no options yet */} 55 + {(pollOptions.every((option) => option.value === "") || 56 + pollOptions.length === 0) && 57 + pollState !== "editing" && ( 58 + <div className="text-center italic text-tertiary text-sm"> 59 + no options yet... 60 + </div> 61 + )} 62 + 63 + {pollOptions.map((option, index) => ( 64 + <PollOption 65 + key={index} 66 + state={pollState} 67 + setState={setPollState} 68 + optionName={option.value} 69 + setOptionName={(newValue) => { 70 + setPollOptions((oldOptions) => { 71 + let newOptions = [...oldOptions]; 72 + newOptions[index] = { 73 + value: newValue, 74 + votes: oldOptions[index].votes, 75 + }; 76 + return newOptions; 77 + }); 78 + }} 79 + votes={option.votes} 80 + setVotes={(newVotes) => { 81 + setPollOptions((oldOptions) => { 82 + let newOptions = [...oldOptions]; 83 + newOptions[index] = { 84 + value: oldOptions[index].value, 85 + votes: newVotes, 86 + }; 87 + return newOptions; 88 + }); 89 + }} 90 + totalVotes={totalVotes} 91 + winner={winningIndexes.includes(index)} 92 + removeOption={() => { 93 + setPollOptions((oldOptions) => { 94 + let newOptions = [...oldOptions]; 95 + newOptions.splice(index, 1); 96 + return newOptions; 97 + }); 98 + }} 99 + /> 100 + ))} 101 + {!permissions.write ? null : pollState === "editing" ? ( 102 + <> 103 + <AddPollOptionButton 104 + addPollOption={() => { 105 + setPollOptions([...pollOptions, { value: "", votes: 0 }]); 106 + }} 107 + /> 108 + <hr className="border-border" /> 109 + <ButtonPrimary 110 + className="place-self-end" 111 + onMouseDown={() => { 112 + setPollState("voting"); 113 + // TODO: Currently, the options are updated onChange in thier inputs in PollOption. 114 + // However, they should instead be updated when this save button is clicked! 115 + }} 116 + > 117 + Save <CheckTiny /> 118 + </ButtonPrimary> 119 + </> 120 + ) : ( 121 + <div className="flex justify-end gap-2"> 122 + <EditPollOptionsButton state={pollState} setState={setPollState} /> 123 + <Separator classname="h-6" /> 124 + <PollStateToggle setPollState={setPollState} pollState={pollState} /> 125 + </div> 126 + )} 127 + </div> 128 + ); 129 + }; 130 + 131 + const PollOption = (props: { 132 + state: "editing" | "voting" | "results"; 133 + setState: (state: "editing" | "voting" | "results") => void; 134 + optionName: string; 135 + setOptionName: (optionName: string) => void; 136 + votes: number; 137 + setVotes: (votes: number) => void; 138 + totalVotes: number; 139 + winner: boolean; 140 + removeOption: () => void; 141 + }) => { 142 + let [inputValue, setInputValue] = useState(props.optionName); 143 + return props.state === "editing" ? ( 144 + <div className="flex gap-2 items-center"> 145 + <Input 146 + type="text" 147 + className="pollOptionInput w-full input-with-border" 148 + placeholder="Option here..." 149 + disabled={props.votes > 0} 150 + value={inputValue} 151 + onChange={(e) => { 152 + setInputValue(e.target.value); 153 + props.setOptionName(e.target.value); 154 + }} 155 + onKeyDown={(e) => { 156 + if (e.key === "Backspace" && !e.currentTarget.value) { 157 + e.preventDefault(); 158 + props.removeOption(); 159 + } 160 + }} 161 + /> 162 + 163 + <button 164 + disabled={props.votes > 0} 165 + className="text-accent-contrast disabled:text-border" 166 + onMouseDown={() => { 167 + props.removeOption(); 168 + }} 169 + > 170 + <CloseTiny /> 171 + </button> 172 + </div> 173 + ) : props.optionName === "" ? null : props.state === "voting" ? ( 174 + <div className="flex gap-2 items-center"> 175 + <ButtonSecondary 176 + className={`pollOption grow max-w-full`} 177 + onClick={() => { 178 + props.setState("results"); 179 + props.setVotes(props.votes + 1); 180 + }} 181 + > 182 + {props.optionName} 183 + </ButtonSecondary> 184 + </div> 185 + ) : ( 186 + <div 187 + className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`} 188 + > 189 + <div 190 + style={{ 191 + WebkitTextStroke: `${props.winner ? "6px" : "6px"} ${theme.colors["bg-page"]}`, 192 + paintOrder: "stroke fill", 193 + }} 194 + className={`pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10`} 195 + > 196 + <div className="grow max-w-full truncate">{props.optionName}</div> 197 + <div>{props.votes}</div> 198 + </div> 199 + <div 200 + className={`pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0`} 201 + > 202 + <div 203 + className={`bg-accent-contrast rounded-[2px] m-0.5`} 204 + style={{ 205 + maskImage: "var(--hatchSVG)", 206 + maskRepeat: "repeat repeat", 207 + 208 + ...(props.votes === 0 209 + ? { width: "4px" } 210 + : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }), 211 + }} 212 + /> 213 + <div /> 214 + </div> 215 + </div> 216 + ); 217 + }; 218 + 219 + const AddPollOptionButton = (props: { addPollOption: () => void }) => { 220 + return ( 221 + <button 222 + className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 223 + onClick={() => { 224 + props.addPollOption(); 225 + }} 226 + > 227 + Add an Option 228 + </button> 229 + ); 230 + }; 231 + 232 + const EditPollOptionsButton = (props: { 233 + state: "editing" | "voting" | "results"; 234 + setState: (state: "editing" | "voting" | "results") => void; 235 + }) => { 236 + return ( 237 + <button 238 + className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 239 + onClick={() => { 240 + props.setState("editing"); 241 + }} 242 + > 243 + Edit Options{" "} 244 + </button> 245 + ); 246 + }; 247 + 248 + const PollStateToggle = (props: { 249 + setPollState: (pollState: "editing" | "voting" | "results") => void; 250 + pollState: "editing" | "voting" | "results"; 251 + }) => { 252 + return ( 253 + <button 254 + className="text-sm text-accent-contrast sm:hover:underline" 255 + onMouseDown={() => { 256 + props.setPollState(props.pollState === "voting" ? "results" : "voting"); 257 + }} 258 + > 259 + {props.pollState === "voting" ? "See Results" : "Back to Poll"} 260 + </button> 261 + ); 262 + };
+1 -1
components/Blocks/RSVPBlock/index.tsx
··· 209 209 <RSVPBackground /> 210 210 <div className=" relative flex flex-col gap-1 sm:gap-2 z-[1] justify-center w-fit mx-auto"> 211 211 <div 212 - className=" w-fit text-xl text-center text-accent-2 text-with-outline" 212 + className=" w-fit text-xl text-center text-accent-2" 213 213 style={{ 214 214 WebkitTextStroke: `3px ${theme.colors["accent-1"]}`, 215 215 textShadow: `-4px 3px 0 ${theme.colors["accent-1"]}`,
+2 -1
src/replicache/attributes.ts
··· 269 269 | "link" 270 270 | "mailbox" 271 271 | "embed" 272 - | "button"; 272 + | "button" 273 + | "poll"; 273 274 }; 274 275 "canvas-pattern-union": { 275 276 type: "canvas-pattern-union";