a tool for shared writing and social publishing
0
fork

Configure Feed

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

Merge branch 'main' into feature/undo

+951 -106
+1 -1
actions/getIdentityData.ts
··· 21 21 *, 22 22 custom_domains(*), 23 23 home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)), 24 - permission_token_on_homepage(created_at, permission_tokens!inner(*, permission_token_rights(*))) 24 + permission_token_on_homepage(created_at, permission_tokens!inner(id, root_entity, permission_token_rights(*))) 25 25 )`, 26 26 ) 27 27 .eq("id", auth_token)
+18
actions/login.ts
··· 9 9 permission_tokens, 10 10 permission_token_rights, 11 11 permission_token_on_homepage, 12 + poll_votes_on_entity, 12 13 } from "drizzle/schema"; 13 14 import { and, eq, isNull } from "drizzle-orm"; 14 15 import { cookies } from "next/headers"; ··· 21 22 const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 22 23 const db = drizzle(client); 23 24 let token_id = cookies().get("auth_token")?.value; 25 + let voter_token = cookies().get("poll_voter_token")?.value; 24 26 if (!token_id) return null; 25 27 let result = await db.transaction(async (tx) => { 26 28 let [token] = await tx ··· 127 129 128 130 return token; 129 131 }); 132 + if (result?.identity) { 133 + if (result.identity !== voter_token) { 134 + if (voter_token) 135 + await db 136 + .update(poll_votes_on_entity) 137 + .set({ voter_token: result.identity }) 138 + .where(eq(poll_votes_on_entity.voter_token, voter_token)); 139 + 140 + cookies().set("poll_voter_token", result.identity, { 141 + maxAge: 60 * 60 * 24 * 365, 142 + secure: process.env.NODE_ENV === "production", 143 + httpOnly: true, 144 + sameSite: "lax", 145 + }); 146 + } 147 + } 130 148 131 149 client.end(); 132 150 redirect("/home");
+123
actions/pollActions.ts
··· 1 + "use server"; 2 + import { drizzle } from "drizzle-orm/postgres-js"; 3 + import { and, eq, inArray } from "drizzle-orm"; 4 + import postgres from "postgres"; 5 + import { entities, poll_votes_on_entity } from "drizzle/schema"; 6 + import { cookies } from "next/headers"; 7 + import { v7 } from "uuid"; 8 + import { getIdentityData } from "./getIdentityData"; 9 + 10 + export async function getPollData(entity_sets: string[]) { 11 + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 12 + let voter_token = cookies().get("poll_voter_token")?.value; 13 + 14 + const db = drizzle(client); 15 + const polls = await db 16 + .select({ 17 + poll_votes_on_entity: { 18 + poll_entity: poll_votes_on_entity.poll_entity, 19 + voter_token: poll_votes_on_entity.voter_token, 20 + option_entity: poll_votes_on_entity.option_entity, 21 + }, 22 + }) 23 + .from(poll_votes_on_entity) 24 + .innerJoin(entities, eq(entities.id, poll_votes_on_entity.poll_entity)) 25 + .where(and(inArray(entities.set, entity_sets))); 26 + 27 + let pollVotes = polls 28 + .reduce< 29 + Array<{ 30 + poll_entity: string; 31 + votes: { option_entity: string; voter_token: string }[]; 32 + }> 33 + >((acc, p) => { 34 + let x = acc.find( 35 + (a) => a.poll_entity === p.poll_votes_on_entity.poll_entity, 36 + ); 37 + if (!x) 38 + acc.push({ 39 + poll_entity: p.poll_votes_on_entity.poll_entity, 40 + votes: [p.poll_votes_on_entity], 41 + }); 42 + else x.votes.push(p.poll_votes_on_entity); 43 + return acc; 44 + }, []) 45 + .map((poll) => { 46 + return { 47 + poll_entity: poll.poll_entity, 48 + unique_votes: poll.votes.reduce<string[]>((acc, v) => { 49 + if (!acc.includes(v.voter_token)) acc.push(v.voter_token); 50 + return acc; 51 + }, []).length, 52 + votesByOption: poll.votes.reduce<{ 53 + [key: string]: number; 54 + }>((acc, v) => { 55 + if (!acc[v.option_entity]) acc[v.option_entity] = 0; 56 + acc[v.option_entity] = acc[v.option_entity] + 1; 57 + return acc; 58 + }, {}), 59 + }; 60 + }); 61 + 62 + return { 63 + pollVotes, 64 + polls: polls.map((p) => ({ 65 + poll_votes_on_entity: { 66 + ...p.poll_votes_on_entity, 67 + voter_token: 68 + voter_token === p.poll_votes_on_entity.voter_token 69 + ? voter_token 70 + : undefined, 71 + }, 72 + })), 73 + voter_token, 74 + }; 75 + } 76 + 77 + export async function voteOnPoll( 78 + poll_entity: string, 79 + option_entities: string[], 80 + ) { 81 + let voter_token = cookies().get("poll_voter_token")?.value; 82 + if (!voter_token) { 83 + let identity = await getIdentityData(); 84 + if (identity) voter_token = identity.id; 85 + else voter_token = v7(); 86 + cookies().set("poll_voter_token", voter_token, { 87 + maxAge: 60 * 60 * 24 * 365, 88 + secure: process.env.NODE_ENV === "production", 89 + httpOnly: true, 90 + sameSite: "lax", 91 + }); 92 + } 93 + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 94 + const db = drizzle(client); 95 + 96 + const pollVote = await db 97 + .select() 98 + .from(poll_votes_on_entity) 99 + .where(eq(poll_votes_on_entity.poll_entity, poll_entity)); 100 + 101 + if ( 102 + pollVote.find((v) => { 103 + return v.voter_token === voter_token; 104 + }) 105 + ) { 106 + await db 107 + .delete(poll_votes_on_entity) 108 + .where( 109 + and( 110 + eq(poll_votes_on_entity.voter_token, voter_token), 111 + eq(poll_votes_on_entity.poll_entity, poll_entity), 112 + ), 113 + ); 114 + } 115 + 116 + await db.insert(poll_votes_on_entity).values( 117 + option_entities.map((option_entity) => ({ 118 + option_entity, 119 + poll_entity, 120 + voter_token, 121 + })), 122 + ); 123 + }
+5 -2
app/[leaflet_id]/page.tsx
··· 11 11 import { scanIndexLocal } from "src/replicache/utils"; 12 12 import { getRSVPData } from "actions/getRSVPData"; 13 13 import { PageSWRDataProvider } from "components/PageSWRDataProvider"; 14 + import { getPollData } from "actions/pollActions"; 14 15 15 16 export const preferredRegion = ["sfo1"]; 16 17 export const dynamic = "force-dynamic"; ··· 34 35 .eq("id", props.params.leaflet_id) 35 36 .single(); 36 37 let rootEntity = res.data?.root_entity; 37 - if (!rootEntity || !res.data) 38 + if (!rootEntity || !res.data || res.data.blocked_by_admin) 38 39 return ( 39 40 <div className="w-screen h-screen flex place-items-center bg-bg-leaflet"> 40 41 <div className="bg-bg-page mx-auto p-4 border border-border rounded-md flex flex-col text-center justify-centergap-1 w-fit"> ··· 52 53 </div> 53 54 ); 54 55 55 - let [{ data }, rsvp_data] = await Promise.all([ 56 + let [{ data }, rsvp_data, poll_data] = await Promise.all([ 56 57 supabase.rpc("get_facts", { 57 58 root: rootEntity, 58 59 }), 59 60 getRSVPData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), 61 + getPollData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), 60 62 ]); 61 63 let initialFacts = (data as unknown as Fact<keyof typeof Attributes>[]) || []; 62 64 return ( 63 65 <PageSWRDataProvider 64 66 rsvp_data={rsvp_data} 67 + poll_data={poll_data} 65 68 leaflet_id={res.data.id} 66 69 domains={res.data.custom_domain_routes} 67 70 >
-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) => {
+55 -1
components/Blocks/BlockCommands.tsx
··· 14 14 BlockButtonSmall, 15 15 BlockCalendarSmall, 16 16 RSVPSmall, 17 + BlockPollSmall, 17 18 } from "components/Icons"; 18 19 import { generateKeyBetween } from "fractional-indexing"; 19 20 import { focusPage } from "components/Pages"; ··· 24 25 import { elementId } from "src/utils/elementId"; 25 26 import { UndoManager } from "src/undoManager"; 26 27 import { focusBlock } from "src/utils/focusBlock"; 28 + import { usePollBlockUIState } from "./PollBlock"; 29 + import { focusElement } from "components/Input"; 27 30 28 31 type Props = { 29 32 parent: string; ··· 264 267 name: "Mailbox", 265 268 icon: <BlockMailboxSmall />, 266 269 type: "block", 267 - onSelect: async (rep, props) => { 270 + onSelect: async (rep, props, um) => { 268 271 props.entityID && clearCommandSearchText(props.entityID); 269 272 await createBlockWithType(rep, props, "mailbox"); 273 + um.add({ 274 + undo: () => { 275 + props.entityID && keepFocus(props.entityID); 276 + }, 277 + redo: () => {}, 278 + }); 279 + }, 280 + }, 281 + { 282 + name: "Poll", 283 + icon: <BlockPollSmall />, 284 + type: "block", 285 + onSelect: async (rep, props, um) => { 286 + let entity = await createBlockWithType(rep, props, "poll"); 287 + let pollOptionEntity = v7(); 288 + await rep.mutate.addPollOption({ 289 + pollEntity: entity, 290 + pollOptionEntity, 291 + pollOptionName: "", 292 + factID: v7(), 293 + permission_set: props.entity_set, 294 + }); 295 + await rep.mutate.addPollOption({ 296 + pollEntity: entity, 297 + pollOptionEntity: v7(), 298 + pollOptionName: "", 299 + factID: v7(), 300 + permission_set: props.entity_set, 301 + }); 302 + usePollBlockUIState.setState((s) => ({ [entity]: { state: "editing" } })); 303 + setTimeout(() => { 304 + focusElement( 305 + document.getElementById( 306 + elementId.block(entity).pollInput(pollOptionEntity), 307 + ) as HTMLInputElement | null, 308 + ); 309 + }, 20); 310 + um.add({ 311 + undo: () => { 312 + props.entityID && keepFocus(props.entityID); 313 + }, 314 + redo: () => { 315 + setTimeout(() => { 316 + focusElement( 317 + document.getElementById( 318 + elementId.block(entity).pollInput(pollOptionEntity), 319 + ) as HTMLInputElement | null, 320 + ); 321 + }, 20); 322 + }, 323 + }); 270 324 }, 271 325 }, 272 326
+454
components/Blocks/PollBlock.tsx
··· 1 + import { useUIState } from "src/useUIState"; 2 + import { BlockProps } from "./Block"; 3 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 4 + import { useCallback, useEffect, useState } from "react"; 5 + import { focusElement, 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 + import { useEntity, useReplicache } from "src/replicache"; 11 + import { v7 } from "uuid"; 12 + import { usePollData } from "components/PageSWRDataProvider"; 13 + import { voteOnPoll } from "actions/pollActions"; 14 + import { create } from "zustand"; 15 + import { poll_votes_on_entity } from "drizzle/schema"; 16 + import { elementId } from "src/utils/elementId"; 17 + 18 + export let usePollBlockUIState = create( 19 + () => 20 + ({}) as { 21 + [entity: string]: { state: "editing" | "voting" | "results" } | undefined; 22 + }, 23 + ); 24 + export const PollBlock = (props: BlockProps) => { 25 + let isSelected = useUIState((s) => 26 + s.selectedBlocks.find((b) => b.value === props.entityID), 27 + ); 28 + let { permissions } = useEntitySetContext(); 29 + 30 + let { data: pollData } = usePollData(); 31 + let hasVoted = 32 + pollData?.voter_token && 33 + pollData.polls.find( 34 + (v) => 35 + v.poll_votes_on_entity.voter_token === pollData.voter_token && 36 + v.poll_votes_on_entity.poll_entity === props.entityID, 37 + ); 38 + 39 + let pollState = usePollBlockUIState((s) => s[props.entityID]?.state); 40 + if (!pollState) { 41 + if (hasVoted) pollState = "results"; 42 + else pollState = "voting"; 43 + } 44 + 45 + const setPollState = useCallback( 46 + (state: "editing" | "voting" | "results") => { 47 + usePollBlockUIState.setState((s) => ({ [props.entityID]: { state } })); 48 + }, 49 + [], 50 + ); 51 + 52 + let votes = 53 + pollData?.polls.filter( 54 + (v) => v.poll_votes_on_entity.poll_entity === props.entityID, 55 + ) || []; 56 + let totalVotes = votes.length; 57 + 58 + return ( 59 + <div 60 + className={`poll flex flex-col gap-2 p-3 w-full 61 + ${isSelected ? "block-border-selected " : "block-border"}`} 62 + style={{ 63 + backgroundColor: 64 + "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 65 + }} 66 + > 67 + {pollState === "editing" ? ( 68 + <EditPoll 69 + totalVotes={totalVotes} 70 + votes={votes.map((v) => v.poll_votes_on_entity)} 71 + entityID={props.entityID} 72 + close={() => { 73 + if (hasVoted) setPollState("results"); 74 + else setPollState("voting"); 75 + }} 76 + /> 77 + ) : pollState === "results" ? ( 78 + <PollResults entityID={props.entityID} /> 79 + ) : ( 80 + <PollVote 81 + entityID={props.entityID} 82 + onSubmit={() => setPollState("results")} 83 + /> 84 + )} 85 + 86 + {pollState !== "editing" && ( 87 + <div className="flex justify-end gap-2"> 88 + {permissions.write && ( 89 + <button 90 + className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 91 + onClick={() => { 92 + setPollState("editing"); 93 + }} 94 + > 95 + Edit Options{" "} 96 + </button> 97 + )} 98 + 99 + {permissions.write && <Separator classname="h-6" />} 100 + <PollStateToggle 101 + setPollState={setPollState} 102 + pollState={pollState} 103 + hasVoted={!!hasVoted} 104 + /> 105 + </div> 106 + )} 107 + </div> 108 + ); 109 + }; 110 + 111 + const PollVote = (props: { entityID: string; onSubmit: () => void }) => { 112 + let { data, mutate } = usePollData(); 113 + let pollOptions = useEntity(props.entityID, "poll/options"); 114 + let currentVotes = data?.voter_token 115 + ? data.polls 116 + .filter( 117 + (p) => 118 + p.poll_votes_on_entity.poll_entity === props.entityID && 119 + p.poll_votes_on_entity.voter_token === data.voter_token, 120 + ) 121 + .map((v) => v.poll_votes_on_entity.option_entity) 122 + : []; 123 + let [selectedPollOptions, setSelectedPollOptions] = 124 + useState<string[]>(currentVotes); 125 + 126 + return ( 127 + <> 128 + {pollOptions.map((option, index) => ( 129 + <PollVoteButton 130 + key={option.data.value} 131 + selected={selectedPollOptions.includes(option.data.value)} 132 + toggleSelected={() => 133 + setSelectedPollOptions((s) => 134 + s.includes(option.data.value) 135 + ? s.filter((s) => s !== option.data.value) 136 + : [...s, option.data.value], 137 + ) 138 + } 139 + entityID={option.data.value} 140 + /> 141 + ))} 142 + <ButtonPrimary 143 + className="place-self-end" 144 + onClick={async () => { 145 + await voteOnPoll(props.entityID, selectedPollOptions); 146 + mutate((oldState) => { 147 + if (!oldState || !oldState.voter_token) return; 148 + return { 149 + ...oldState, 150 + polls: [ 151 + ...oldState.polls.filter( 152 + (p) => 153 + !( 154 + p.poll_votes_on_entity.voter_token === 155 + oldState.voter_token && 156 + p.poll_votes_on_entity.poll_entity == props.entityID 157 + ), 158 + ), 159 + ...selectedPollOptions.map((option_entity) => ({ 160 + poll_votes_on_entity: { 161 + option_entity, 162 + poll_entity: props.entityID, 163 + voter_token: oldState.voter_token!, 164 + }, 165 + })), 166 + ], 167 + }; 168 + }); 169 + props.onSubmit(); 170 + }} 171 + disabled={ 172 + selectedPollOptions.length === 0 || 173 + (selectedPollOptions.length === currentVotes.length && 174 + selectedPollOptions.every((s) => currentVotes.includes(s))) 175 + } 176 + > 177 + Vote! 178 + </ButtonPrimary> 179 + </> 180 + ); 181 + }; 182 + const PollVoteButton = (props: { 183 + entityID: string; 184 + selected: boolean; 185 + toggleSelected: () => void; 186 + }) => { 187 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 188 + if (!optionName) return null; 189 + if (props.selected) 190 + return ( 191 + <div className="flex gap-2 items-center"> 192 + <ButtonPrimary 193 + className={`pollOption grow max-w-full flex`} 194 + onClick={() => { 195 + props.toggleSelected(); 196 + }} 197 + > 198 + {optionName} 199 + </ButtonPrimary> 200 + </div> 201 + ); 202 + return ( 203 + <div className="flex gap-2 items-center"> 204 + <ButtonSecondary 205 + className={`pollOption grow max-w-full flex`} 206 + onClick={() => { 207 + props.toggleSelected(); 208 + }} 209 + > 210 + {optionName} 211 + </ButtonSecondary> 212 + </div> 213 + ); 214 + }; 215 + 216 + const PollResults = (props: { entityID: string }) => { 217 + let { data } = usePollData(); 218 + let pollOptions = useEntity(props.entityID, "poll/options"); 219 + let pollData = data?.pollVotes.find((p) => p.poll_entity === props.entityID); 220 + let votesByOptions = pollData?.votesByOption || {}; 221 + let highestVotes = Math.max(...Object.values(votesByOptions)); 222 + let winningOptionEntities = Object.entries(votesByOptions).reduce<string[]>( 223 + (winningEntities, [entity, votes]) => { 224 + if (votes === highestVotes) winningEntities.push(entity); 225 + return winningEntities; 226 + }, 227 + [], 228 + ); 229 + return ( 230 + <> 231 + {pollOptions.map((p) => ( 232 + <PollResult 233 + key={p.id} 234 + winner={winningOptionEntities.includes(p.data.value)} 235 + entityID={p.data.value} 236 + totalVotes={pollData?.unique_votes || 0} 237 + votes={pollData?.votesByOption[p.data.value] || 0} 238 + /> 239 + ))} 240 + </> 241 + ); 242 + }; 243 + 244 + const PollResult = (props: { 245 + entityID: string; 246 + votes: number; 247 + totalVotes: number; 248 + winner: boolean; 249 + }) => { 250 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 251 + return ( 252 + <div 253 + className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`} 254 + > 255 + <div 256 + style={{ 257 + WebkitTextStroke: `${props.winner ? "6px" : "6px"} ${theme.colors["bg-page"]}`, 258 + paintOrder: "stroke fill", 259 + }} 260 + className={`pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10`} 261 + > 262 + <div className="grow max-w-full truncate">{optionName}</div> 263 + <div>{props.votes}</div> 264 + </div> 265 + <div 266 + className={`pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0`} 267 + > 268 + <div 269 + className={`bg-accent-contrast rounded-[2px] m-0.5`} 270 + style={{ 271 + maskImage: "var(--hatchSVG)", 272 + maskRepeat: "repeat repeat", 273 + 274 + ...(props.votes === 0 275 + ? { width: "4px" } 276 + : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }), 277 + }} 278 + /> 279 + <div /> 280 + </div> 281 + </div> 282 + ); 283 + }; 284 + 285 + const EditPoll = (props: { 286 + votes: { option_entity: string }[]; 287 + totalVotes: number; 288 + entityID: string; 289 + close: () => void; 290 + }) => { 291 + let pollOptions = useEntity(props.entityID, "poll/options"); 292 + let { rep } = useReplicache(); 293 + let permission_set = useEntitySetContext(); 294 + let [localPollOptionNames, setLocalPollOptionNames] = useState<{ 295 + [k: string]: string; 296 + }>({}); 297 + return ( 298 + <> 299 + {props.totalVotes > 0 && ( 300 + <div className="text-sm italic text-tertiary"> 301 + You can&apos;t edit options people already voted for! 302 + </div> 303 + )} 304 + 305 + {pollOptions.length === 0 && ( 306 + <div className="text-center italic text-tertiary text-sm"> 307 + no options yet... 308 + </div> 309 + )} 310 + {pollOptions.map((p) => ( 311 + <EditPollOption 312 + entityID={p.data.value} 313 + pollEntity={props.entityID} 314 + disabled={!!props.votes.find((v) => v.option_entity === p.data.value)} 315 + localNameState={localPollOptionNames[p.data.value]} 316 + setLocalNameState={setLocalPollOptionNames} 317 + /> 318 + ))} 319 + 320 + <button 321 + className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 322 + onClick={async () => { 323 + let pollOptionEntity = v7(); 324 + await rep?.mutate.addPollOption({ 325 + pollEntity: props.entityID, 326 + pollOptionEntity, 327 + pollOptionName: "", 328 + permission_set: permission_set.set, 329 + factID: v7(), 330 + }); 331 + 332 + focusElement( 333 + document.getElementById( 334 + elementId.block(props.entityID).pollInput(pollOptionEntity), 335 + ) as HTMLInputElement | null, 336 + ); 337 + }} 338 + > 339 + Add an Option 340 + </button> 341 + 342 + <hr className="border-border" /> 343 + <ButtonPrimary 344 + className="place-self-end" 345 + onClick={async () => { 346 + // remove any poll options that have no name 347 + // look through the localPollOptionNames object and remove any options that have no name 348 + let emptyOptions = Object.entries(localPollOptionNames).filter( 349 + ([optionEntity, optionName]) => optionName === "", 350 + ); 351 + await Promise.all( 352 + emptyOptions.map( 353 + async ([entity]) => 354 + await rep?.mutate.removePollOption({ 355 + optionEntity: entity, 356 + }), 357 + ), 358 + ); 359 + 360 + await rep?.mutate.assertFact( 361 + Object.entries(localPollOptionNames) 362 + .filter(([, name]) => !!name) 363 + .map(([entity, name]) => ({ 364 + entity, 365 + attribute: "poll-option/name", 366 + data: { type: "string", value: name }, 367 + })), 368 + ); 369 + props.close(); 370 + }} 371 + > 372 + Save <CheckTiny /> 373 + </ButtonPrimary> 374 + </> 375 + ); 376 + }; 377 + 378 + const EditPollOption = (props: { 379 + entityID: string; 380 + pollEntity: string; 381 + localNameState: string | undefined; 382 + setLocalNameState: ( 383 + s: (s: { [k: string]: string }) => { [k: string]: string }, 384 + ) => void; 385 + disabled: boolean; 386 + }) => { 387 + let { rep } = useReplicache(); 388 + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; 389 + useEffect(() => { 390 + props.setLocalNameState((s) => ({ 391 + ...s, 392 + [props.entityID]: optionName || "", 393 + })); 394 + }, [optionName, props.setLocalNameState, props.entityID]); 395 + 396 + return ( 397 + <div className="flex gap-2 items-center"> 398 + <Input 399 + id={elementId.block(props.pollEntity).pollInput(props.entityID)} 400 + type="text" 401 + className="pollOptionInput w-full input-with-border" 402 + placeholder="Option here..." 403 + disabled={props.disabled} 404 + value={ 405 + props.localNameState === undefined ? optionName : props.localNameState 406 + } 407 + onChange={(e) => { 408 + props.setLocalNameState((s) => ({ 409 + ...s, 410 + [props.entityID]: e.target.value, 411 + })); 412 + }} 413 + onKeyDown={(e) => { 414 + if (e.key === "Backspace" && !e.currentTarget.value) { 415 + e.preventDefault(); 416 + rep?.mutate.removePollOption({ optionEntity: props.entityID }); 417 + } 418 + }} 419 + /> 420 + 421 + <button 422 + tabIndex={-1} 423 + disabled={props.disabled} 424 + className="text-accent-contrast disabled:text-border" 425 + onMouseDown={async () => { 426 + await rep?.mutate.removePollOption({ optionEntity: props.entityID }); 427 + }} 428 + > 429 + <CloseTiny /> 430 + </button> 431 + </div> 432 + ); 433 + }; 434 + 435 + const PollStateToggle = (props: { 436 + setPollState: (pollState: "editing" | "voting" | "results") => void; 437 + hasVoted: boolean; 438 + pollState: "editing" | "voting" | "results"; 439 + }) => { 440 + return ( 441 + <button 442 + className="text-sm text-accent-contrast sm:hover:underline" 443 + onClick={() => { 444 + props.setPollState(props.pollState === "voting" ? "results" : "voting"); 445 + }} 446 + > 447 + {props.pollState === "voting" 448 + ? "See Results" 449 + : props.hasVoted 450 + ? "Change Vote" 451 + : "Back to Poll"} 452 + </button> 453 + ); 454 + };
+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"]}`,
+20
components/Icons.tsx
··· 289 289 ); 290 290 }; 291 291 292 + export const BlockPollSmall = (props: Props) => { 293 + return ( 294 + <svg 295 + width="24" 296 + height="25" 297 + viewBox="0 0 24 25" 298 + fill="none" 299 + xmlns="http://www.w3.org/2000/svg" 300 + {...props} 301 + > 302 + <path 303 + fillRule="evenodd" 304 + clipRule="evenodd" 305 + d="M2.64691 1.77699C2.64691 1.36278 2.31113 1.02699 1.89691 1.02699C1.4827 1.02699 1.14691 1.36278 1.14691 1.77699V22.6666C1.14691 23.0809 1.4827 23.4166 1.89691 23.4166C2.31113 23.4166 2.64691 23.0809 2.64691 22.6666V21.5114H17.7079C18.3292 21.5114 18.8329 21.0077 18.8329 20.3864V17.2267C18.8329 16.6054 18.3292 16.1017 17.7079 16.1017H2.64691V15.3808H21.6031C22.2244 15.3808 22.7281 14.8771 22.7281 14.2558V10.1879C22.7281 9.56656 22.2244 9.06288 21.6031 9.06288H2.64691V8.34218H11.8622C12.4835 8.34218 12.9872 7.8385 12.9872 7.21718V4.05751C12.9872 3.43619 12.4835 2.93251 11.8622 2.93251H2.64691V1.77699ZM21.4781 14.1308H2.64691V10.3129H21.4781V14.1308ZM2.64691 7.09218L2.64691 4.18251H11.7372V7.09218H2.64691ZM2.64691 20.2614L2.64691 17.3517H17.5829V20.2614H2.64691ZM11.0916 11.3764C11.2868 11.1811 11.2868 10.8646 11.0916 10.6693C10.8963 10.474 10.5797 10.474 10.3845 10.6693L7.9866 13.0671C7.79133 13.2624 7.79133 13.579 7.98659 13.7742C8.18186 13.9695 8.49844 13.9695 8.6937 13.7742L11.0916 11.3764ZM13.554 10.6693C13.7492 10.8646 13.7492 11.1811 13.554 11.3764L11.1561 13.7742C10.9608 13.9695 10.6443 13.9695 10.449 13.7742C10.2537 13.579 10.2537 13.2624 10.449 13.0671L12.8469 10.6693C13.0421 10.474 13.3587 10.474 13.554 10.6693ZM16.0163 11.3764C16.2116 11.1811 16.2116 10.8646 16.0163 10.6693C15.8211 10.474 15.5045 10.474 15.3092 10.6693L12.9113 13.0671C12.7161 13.2624 12.7161 13.579 12.9113 13.7742C13.1066 13.9695 13.4232 13.9695 13.6184 13.7742L16.0163 11.3764ZM18.4787 10.6693C18.674 10.8646 18.674 11.1811 18.4787 11.3764L16.0808 13.7742C15.8856 13.9695 15.569 13.9695 15.3737 13.7742C15.1785 13.579 15.1785 13.2624 15.3737 13.0671L17.7716 10.6693C17.9669 10.474 18.2835 10.474 18.4787 10.6693ZM20.9411 11.3764C21.1363 11.1811 21.1363 10.8646 20.9411 10.6693C20.7458 10.474 20.4292 10.474 20.234 10.6693L17.8361 13.0671C17.6408 13.2624 17.6408 13.579 17.8361 13.7742C18.0313 13.9695 18.3479 13.9695 18.5432 13.7742L20.9411 11.3764ZM21.0571 13.0156C21.2523 13.2109 21.2523 13.5275 21.0571 13.7228L21.0055 13.7743C20.8103 13.9695 20.4937 13.9695 20.2984 13.7743C20.1032 13.579 20.1032 13.2624 20.2984 13.0672L20.3499 13.0156C20.5452 12.8204 20.8618 12.8204 21.0571 13.0156ZM3.7045 11.3764C3.89978 11.1812 3.8998 10.8646 3.70455 10.6693C3.5093 10.474 3.19271 10.474 2.99744 10.6693L2.89055 10.7761C2.69527 10.9714 2.69525 11.288 2.8905 11.4833C3.08575 11.6785 3.40234 11.6785 3.59761 11.4833L3.7045 11.3764ZM6.16689 10.6693C6.36215 10.8646 6.36215 11.1811 6.16689 11.3764L3.76902 13.7742C3.57375 13.9695 3.25717 13.9695 3.06191 13.7742C2.86665 13.579 2.86665 13.2624 3.06191 13.0671L5.45978 10.6693C5.65505 10.474 5.97163 10.474 6.16689 10.6693ZM8.62923 11.3764C8.82449 11.1811 8.82449 10.8646 8.62923 10.6693C8.43397 10.474 8.11739 10.474 7.92212 10.6693L5.52426 13.0671C5.32899 13.2624 5.32899 13.579 5.52425 13.7742C5.71951 13.9695 6.0361 13.9695 6.23136 13.7742L8.62923 11.3764Z" 306 + fill="currentColor" 307 + /> 308 + </svg> 309 + ); 310 + }; 311 + 292 312 export const CanvasWidenSmall = (props: Props) => { 293 313 return ( 294 314 <svg
+35 -26
components/Input.tsx
··· 12 12 useEffect(() => { 13 13 if (!isIOS()) return; 14 14 if (props.autoFocus) { 15 - let fakeInput = document.createElement("input"); 16 - fakeInput.setAttribute("type", "text"); 17 - fakeInput.style.position = "fixed"; 18 - fakeInput.style.height = "0px"; 19 - fakeInput.style.width = "0px"; 20 - fakeInput.style.fontSize = "16px"; // disable auto zoom 21 - document.body.appendChild(fakeInput); 22 - fakeInput.focus(); 23 - setTimeout(() => { 24 - if (!ref.current) return; 25 - ref.current.style.transform = "translateY(-2000px)"; 26 - ref.current?.focus(); 27 - fakeInput.remove(); 28 - ref.current.value = " "; 29 - ref.current.setSelectionRange(1, 1); 30 - requestAnimationFrame(() => { 31 - if (ref.current) { 32 - ref.current.style.transform = ""; 33 - } 34 - }); 35 - setTimeout(() => { 36 - if (!ref.current) return; 37 - ref.current.value = ""; 38 - ref.current.setSelectionRange(0, 0); 39 - }, 50); 40 - }, 20); 15 + focusElement(ref.current); 41 16 } 42 17 }, [props.autoFocus]); 43 18 ··· 50 25 /> 51 26 ); 52 27 } 28 + 29 + export const focusElement = (el?: HTMLInputElement | null) => { 30 + if (!isIOS()) { 31 + el?.focus(); 32 + return; 33 + } 34 + 35 + let fakeInput = document.createElement("input"); 36 + fakeInput.setAttribute("type", "text"); 37 + fakeInput.style.position = "fixed"; 38 + fakeInput.style.height = "0px"; 39 + fakeInput.style.width = "0px"; 40 + fakeInput.style.fontSize = "16px"; // disable auto zoom 41 + document.body.appendChild(fakeInput); 42 + fakeInput.focus(); 43 + setTimeout(() => { 44 + if (!el) return; 45 + el.style.transform = "translateY(-2000px)"; 46 + el?.focus(); 47 + fakeInput.remove(); 48 + el.value = " "; 49 + el.setSelectionRange(1, 1); 50 + requestAnimationFrame(() => { 51 + if (el) { 52 + el.style.transform = ""; 53 + } 54 + }); 55 + setTimeout(() => { 56 + if (!el) return; 57 + el.value = ""; 58 + el.setSelectionRange(0, 0); 59 + }, 50); 60 + }, 20); 61 + }; 53 62 54 63 export const InputWithLabel = ( 55 64 props: {
+11
components/PageSWRDataProvider.tsx
··· 4 4 import { useReplicache } from "src/replicache"; 5 5 import useSWR from "swr"; 6 6 import { callRPC } from "app/api/rpc/client"; 7 + import { getPollData } from "actions/pollActions"; 7 8 8 9 export function PageSWRDataProvider(props: { 9 10 leaflet_id: string; 10 11 domains: { domain: string }[]; 11 12 rsvp_data: Awaited<ReturnType<typeof getRSVPData>>; 13 + poll_data: Awaited<ReturnType<typeof getPollData>>; 12 14 children: React.ReactNode; 13 15 }) { 14 16 return ( ··· 16 18 value={{ 17 19 fallback: { 18 20 rsvp_data: props.rsvp_data, 21 + poll_data: props.poll_data, 19 22 [`${props.leaflet_id}-domains`]: props.domains, 20 23 }, 21 24 }} ··· 29 32 let { permission_token } = useReplicache(); 30 33 return useSWR(`rsvp_data`, () => 31 34 getRSVPData( 35 + permission_token.permission_token_rights.map((pr) => pr.entity_set), 36 + ), 37 + ); 38 + } 39 + export function usePollData() { 40 + let { permission_token } = useReplicache(); 41 + return useSWR(`poll_data`, () => 42 + getPollData( 32 43 permission_token.permission_token_rights.map((pr) => pr.entity_set), 33 44 ), 34 45 );
+45 -26
drizzle/relations.ts
··· 1 1 import { relations } from "drizzle-orm/relations"; 2 - import { entities, facts, entity_sets, permission_tokens, identities, email_subscriptions_to_entity, email_auth_tokens, custom_domains, custom_domain_routes, phone_rsvps_to_entity, permission_token_on_homepage, permission_token_rights } from "./schema"; 2 + import { entities, poll_votes_on_entity, entity_sets, facts, permission_tokens, identities, email_subscriptions_to_entity, email_auth_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, permission_token_on_homepage, permission_token_rights } from "./schema"; 3 3 4 - export const factsRelations = relations(facts, ({one}) => ({ 5 - entity: one(entities, { 6 - fields: [facts.entity], 7 - references: [entities.id] 4 + export const poll_votes_on_entityRelations = relations(poll_votes_on_entity, ({one}) => ({ 5 + entity_option_entity: one(entities, { 6 + fields: [poll_votes_on_entity.option_entity], 7 + references: [entities.id], 8 + relationName: "poll_votes_on_entity_option_entity_entities_id" 9 + }), 10 + entity_poll_entity: one(entities, { 11 + fields: [poll_votes_on_entity.poll_entity], 12 + references: [entities.id], 13 + relationName: "poll_votes_on_entity_poll_entity_entities_id" 8 14 }), 9 15 })); 10 16 11 17 export const entitiesRelations = relations(entities, ({one, many}) => ({ 12 - facts: many(facts), 18 + poll_votes_on_entities_option_entity: many(poll_votes_on_entity, { 19 + relationName: "poll_votes_on_entity_option_entity_entities_id" 20 + }), 21 + poll_votes_on_entities_poll_entity: many(poll_votes_on_entity, { 22 + relationName: "poll_votes_on_entity_poll_entity_entities_id" 23 + }), 13 24 entity_set: one(entity_sets, { 14 25 fields: [entities.set], 15 26 references: [entity_sets.id] 16 27 }), 28 + facts: many(facts), 17 29 permission_tokens: many(permission_tokens), 18 30 email_subscriptions_to_entities: many(email_subscriptions_to_entity), 19 31 phone_rsvps_to_entities: many(phone_rsvps_to_entity), ··· 24 36 permission_token_rights: many(permission_token_rights), 25 37 })); 26 38 39 + export const factsRelations = relations(facts, ({one}) => ({ 40 + entity: one(entities, { 41 + fields: [facts.entity], 42 + references: [entities.id] 43 + }), 44 + })); 45 + 46 + export const identitiesRelations = relations(identities, ({one, many}) => ({ 47 + permission_token: one(permission_tokens, { 48 + fields: [identities.home_page], 49 + references: [permission_tokens.id] 50 + }), 51 + email_auth_tokens: many(email_auth_tokens), 52 + custom_domains: many(custom_domains), 53 + permission_token_on_homepages: many(permission_token_on_homepage), 54 + })); 55 + 27 56 export const permission_tokensRelations = relations(permission_tokens, ({one, many}) => ({ 57 + identities: many(identities), 28 58 entity: one(entities, { 29 59 fields: [permission_tokens.root_entity], 30 60 references: [entities.id] 31 61 }), 32 - identities: many(identities), 33 62 email_subscriptions_to_entities: many(email_subscriptions_to_entity), 34 63 custom_domain_routes_edit_permission_token: many(custom_domain_routes, { 35 64 relationName: "custom_domain_routes_edit_permission_token_permission_tokens_id" ··· 41 70 permission_token_rights: many(permission_token_rights), 42 71 })); 43 72 44 - export const identitiesRelations = relations(identities, ({one, many}) => ({ 45 - permission_token: one(permission_tokens, { 46 - fields: [identities.home_page], 47 - references: [permission_tokens.id] 48 - }), 49 - email_auth_tokens: many(email_auth_tokens), 50 - custom_domains: many(custom_domains), 51 - permission_token_on_homepages: many(permission_token_on_homepage), 52 - })); 53 - 54 73 export const email_subscriptions_to_entityRelations = relations(email_subscriptions_to_entity, ({one}) => ({ 55 74 entity: one(entities, { 56 75 fields: [email_subscriptions_to_entity.entity], ··· 69 88 }), 70 89 })); 71 90 72 - export const custom_domainsRelations = relations(custom_domains, ({one, many}) => ({ 73 - identity: one(identities, { 74 - fields: [custom_domains.identity], 75 - references: [identities.email] 91 + export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({ 92 + entity: one(entities, { 93 + fields: [phone_rsvps_to_entity.entity], 94 + references: [entities.id] 76 95 }), 77 - custom_domain_routes: many(custom_domain_routes), 78 96 })); 79 97 80 98 export const custom_domain_routesRelations = relations(custom_domain_routes, ({one}) => ({ ··· 94 112 }), 95 113 })); 96 114 97 - export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({ 98 - entity: one(entities, { 99 - fields: [phone_rsvps_to_entity.entity], 100 - references: [entities.id] 115 + export const custom_domainsRelations = relations(custom_domains, ({one, many}) => ({ 116 + custom_domain_routes: many(custom_domain_routes), 117 + identity: one(identities, { 118 + fields: [custom_domains.identity], 119 + references: [identities.email] 101 120 }), 102 121 })); 103 122
+38 -30
drizzle/schema.ts
··· 1 - import { pgTable, foreignKey, pgEnum, uuid, text, jsonb, timestamp, bigint, unique, boolean, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core" 1 + import { pgTable, foreignKey, pgEnum, uuid, timestamp, text, jsonb, bigint, unique, boolean, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core" 2 2 import { sql } from "drizzle-orm" 3 3 4 4 export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3']) ··· 14 14 export const equality_op = pgEnum("equality_op", ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in']) 15 15 16 16 17 + export const poll_votes_on_entity = pgTable("poll_votes_on_entity", { 18 + id: uuid("id").defaultRandom().primaryKey().notNull(), 19 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 20 + poll_entity: uuid("poll_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 21 + option_entity: uuid("option_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 22 + voter_token: uuid("voter_token").notNull(), 23 + }); 24 + 25 + export const entities = pgTable("entities", { 26 + id: uuid("id").primaryKey().notNull(), 27 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 28 + set: uuid("set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), 29 + }); 30 + 17 31 export const facts = pgTable("facts", { 18 32 id: uuid("id").primaryKey().notNull(), 19 33 entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "restrict" } ), ··· 32 46 last_mutation: bigint("last_mutation", { mode: "number" }).notNull(), 33 47 }); 34 48 35 - export const entities = pgTable("entities", { 36 - id: uuid("id").primaryKey().notNull(), 37 - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 38 - set: uuid("set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), 39 - }); 40 - 41 49 export const entity_sets = pgTable("entity_sets", { 42 50 id: uuid("id").defaultRandom().primaryKey().notNull(), 43 51 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 44 52 }); 45 53 46 - export const permission_tokens = pgTable("permission_tokens", { 47 - id: uuid("id").defaultRandom().primaryKey().notNull(), 48 - root_entity: uuid("root_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 49 - }); 50 - 51 54 export const identities = pgTable("identities", { 52 55 id: uuid("id").defaultRandom().primaryKey().notNull(), 53 56 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), ··· 58 61 return { 59 62 identities_email_key: unique("identities_email_key").on(table.email), 60 63 } 64 + }); 65 + 66 + export const permission_tokens = pgTable("permission_tokens", { 67 + id: uuid("id").defaultRandom().primaryKey().notNull(), 68 + root_entity: uuid("root_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 61 69 }); 62 70 63 71 export const email_subscriptions_to_entity = pgTable("email_subscriptions_to_entity", { ··· 88 96 country_code: text("country_code").notNull(), 89 97 }); 90 98 91 - export const custom_domains = pgTable("custom_domains", { 92 - domain: text("domain").primaryKey().notNull(), 93 - identity: text("identity").default('').notNull().references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ), 94 - confirmed: boolean("confirmed").notNull(), 99 + export const phone_rsvps_to_entity = pgTable("phone_rsvps_to_entity", { 95 100 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 101 + phone_number: text("phone_number").notNull(), 102 + country_code: text("country_code").notNull(), 103 + status: rsvp_status("status").notNull(), 104 + id: uuid("id").defaultRandom().primaryKey().notNull(), 105 + entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 106 + name: text("name").default('').notNull(), 107 + plus_ones: smallint("plus_ones").default(0).notNull(), 108 + }, 109 + (table) => { 110 + return { 111 + unique_phone_number_entities: uniqueIndex("unique_phone_number_entities").on(table.phone_number, table.entity), 112 + } 96 113 }); 97 114 98 115 export const custom_domain_routes = pgTable("custom_domain_routes", { 99 116 id: uuid("id").defaultRandom().primaryKey().notNull(), 100 117 domain: text("domain").notNull().references(() => custom_domains.domain), 101 118 route: text("route").notNull(), 102 - view_permission_token: uuid("view_permission_token").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), 103 119 edit_permission_token: uuid("edit_permission_token").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), 120 + view_permission_token: uuid("view_permission_token").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), 104 121 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 105 122 }, 106 123 (table) => { ··· 109 126 } 110 127 }); 111 128 112 - export const phone_rsvps_to_entity = pgTable("phone_rsvps_to_entity", { 129 + export const custom_domains = pgTable("custom_domains", { 130 + domain: text("domain").primaryKey().notNull(), 131 + identity: text("identity").default('').notNull().references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ), 132 + confirmed: boolean("confirmed").notNull(), 113 133 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 114 - phone_number: text("phone_number").notNull(), 115 - country_code: text("country_code").notNull(), 116 - status: rsvp_status("status").notNull(), 117 - id: uuid("id").defaultRandom().primaryKey().notNull(), 118 - entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 119 - name: text("name").default('').notNull(), 120 - plus_ones: smallint("plus_ones").default(0).notNull(), 121 - }, 122 - (table) => { 123 - return { 124 - unique_phone_number_entities: uniqueIndex("unique_phone_number_entities").on(table.phone_number, table.entity), 125 - } 126 134 }); 127 135 128 136 export const permission_token_on_homepage = pgTable("permission_token_on_homepage", {
+14 -1
src/replicache/attributes.ts
··· 141 141 }, 142 142 } as const; 143 143 144 + const PollBlockAttributes = { 145 + "poll/options": { 146 + type: "ordered-reference", 147 + cardinality: "many", 148 + }, 149 + "poll-option/name": { 150 + type: "string", 151 + cardinality: "one", 152 + }, 153 + } as const; 154 + 144 155 export const ThemeAttributes = { 145 156 "theme/page-leaflet-watermark": { 146 157 type: "boolean", ··· 210 221 ...EmbedBlockAttributes, 211 222 ...ButtonBlockAttributes, 212 223 ...ImageBlockAttributes, 224 + ...PollBlockAttributes, 213 225 }; 214 226 type Attribute = typeof Attributes; 215 227 export type Data<A extends keyof typeof Attributes> = { ··· 269 281 | "link" 270 282 | "mailbox" 271 283 | "embed" 272 - | "button"; 284 + | "button" 285 + | "poll"; 273 286 }; 274 287 "canvas-pattern-union": { 275 288 type: "canvas-pattern-union";
+13 -2
src/replicache/index.tsx
··· 219 219 }, 220 220 ); 221 221 let d = data || fallbackData; 222 - return Attributes[attribute].cardinality === "many" 223 - ? (d as CardinalityResult<A>) 222 + let a = Attributes[attribute]; 223 + return a.cardinality === "many" 224 + ? ((a.type === "ordered-reference" 225 + ? d.sort((a, b) => { 226 + return ( 227 + a as Fact<keyof FilterAttributes<{ type: "ordered-reference" }>> 228 + ).data.position > 229 + (b as Fact<keyof FilterAttributes<{ type: "ordered-reference" }>>) 230 + .data.position 231 + ? 1 232 + : -1; 233 + }) 234 + : d) as CardinalityResult<A>) 224 235 : d.length === 0 && data === null 225 236 ? (null as CardinalityResult<A>) 226 237 : (d[0] as CardinalityResult<A>);
+43
src/replicache/mutations.ts
··· 563 563 } 564 564 }; 565 565 566 + const addPollOption: Mutation<{ 567 + pollEntity: string; 568 + pollOptionEntity: string; 569 + pollOptionName: string; 570 + permission_set: string; 571 + factID: string; 572 + }> = async (args, ctx) => { 573 + await ctx.createEntity({ 574 + entityID: args.pollOptionEntity, 575 + permission_set: args.permission_set, 576 + }); 577 + 578 + await ctx.assertFact({ 579 + entity: args.pollOptionEntity, 580 + attribute: "poll-option/name", 581 + data: { type: "string", value: args.pollOptionName }, 582 + }); 583 + 584 + let children = await ctx.scanIndex.eav(args.pollEntity, "poll/options"); 585 + let lastChild = children.toSorted((a, b) => 586 + a.data.position > b.data.position ? 1 : -1, 587 + )[children.length - 1]; 588 + 589 + await ctx.assertFact({ 590 + entity: args.pollEntity, 591 + id: args.factID, 592 + attribute: "poll/options", 593 + data: { 594 + type: "ordered-reference", 595 + value: args.pollOptionEntity, 596 + position: generateKeyBetween(lastChild?.data.position || null, null), 597 + }, 598 + }); 599 + }; 600 + 601 + const removePollOption: Mutation<{ 602 + optionEntity: string; 603 + }> = async (args, ctx) => { 604 + await ctx.deleteEntity(args.optionEntity); 605 + }; 606 + 566 607 export const mutations = { 567 608 retractAttribute, 568 609 addBlock, ··· 583 624 toggleTodoState, 584 625 createDraft, 585 626 createEntity, 627 + addPollOption, 628 + removePollOption, 586 629 };
+1
src/utils/elementId.ts
··· 3 3 text: `block/${id}/content`, 4 4 container: `block/${id}/container`, 5 5 input: `block/${id}/input`, 6 + pollInput: (entity: string) => `block/${id}/poll-input/${entity}`, 6 7 }), 7 8 page: (id: string) => ({ 8 9 container: `page/${id}/container`,
+7
supabase/database.types.ts
··· 380 380 } 381 381 permission_tokens: { 382 382 Row: { 383 + blocked_by_admin: boolean | null 383 384 id: string 384 385 root_entity: string 385 386 } 386 387 Insert: { 388 + blocked_by_admin?: boolean | null 387 389 id?: string 388 390 root_entity: string 389 391 } 390 392 Update: { 393 + blocked_by_admin?: boolean | null 391 394 id?: string 392 395 root_entity?: string 393 396 } ··· 815 818 metadata: Json 816 819 updated_at: string 817 820 }[] 821 + } 822 + operation: { 823 + Args: Record<PropertyKey, never> 824 + Returns: string 818 825 } 819 826 search: { 820 827 Args: {
+1
supabase/migrations/20250219192237_add_blocked_by_admin_column_to_permission_tokens.sql
··· 1 + alter table "public"."permission_tokens" add column "blocked_by_admin" boolean;
+64
supabase/migrations/20250220195709_add_poll_tables.sql
··· 1 + create table "public"."poll_votes_on_entity" ( 2 + "id" uuid not null default gen_random_uuid(), 3 + "created_at" timestamp with time zone not null default now(), 4 + "poll_entity" uuid not null, 5 + "option_entity" uuid not null, 6 + "voter_token" uuid not null 7 + ); 8 + 9 + 10 + alter table "public"."poll_votes_on_entity" enable row level security; 11 + 12 + CREATE UNIQUE INDEX poll_votes_on_entity_pkey ON public.poll_votes_on_entity USING btree (id); 13 + 14 + alter table "public"."poll_votes_on_entity" add constraint "poll_votes_on_entity_pkey" PRIMARY KEY using index "poll_votes_on_entity_pkey"; 15 + 16 + alter table "public"."poll_votes_on_entity" add constraint "poll_votes_on_entity_option_entity_fkey" FOREIGN KEY (option_entity) REFERENCES entities(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; 17 + 18 + alter table "public"."poll_votes_on_entity" validate constraint "poll_votes_on_entity_option_entity_fkey"; 19 + 20 + alter table "public"."poll_votes_on_entity" add constraint "poll_votes_on_entity_poll_entity_fkey" FOREIGN KEY (poll_entity) REFERENCES entities(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; 21 + 22 + alter table "public"."poll_votes_on_entity" validate constraint "poll_votes_on_entity_poll_entity_fkey"; 23 + 24 + grant delete on table "public"."poll_votes_on_entity" to "anon"; 25 + 26 + grant insert on table "public"."poll_votes_on_entity" to "anon"; 27 + 28 + grant references on table "public"."poll_votes_on_entity" to "anon"; 29 + 30 + grant select on table "public"."poll_votes_on_entity" to "anon"; 31 + 32 + grant trigger on table "public"."poll_votes_on_entity" to "anon"; 33 + 34 + grant truncate on table "public"."poll_votes_on_entity" to "anon"; 35 + 36 + grant update on table "public"."poll_votes_on_entity" to "anon"; 37 + 38 + grant delete on table "public"."poll_votes_on_entity" to "authenticated"; 39 + 40 + grant insert on table "public"."poll_votes_on_entity" to "authenticated"; 41 + 42 + grant references on table "public"."poll_votes_on_entity" to "authenticated"; 43 + 44 + grant select on table "public"."poll_votes_on_entity" to "authenticated"; 45 + 46 + grant trigger on table "public"."poll_votes_on_entity" to "authenticated"; 47 + 48 + grant truncate on table "public"."poll_votes_on_entity" to "authenticated"; 49 + 50 + grant update on table "public"."poll_votes_on_entity" to "authenticated"; 51 + 52 + grant delete on table "public"."poll_votes_on_entity" to "service_role"; 53 + 54 + grant insert on table "public"."poll_votes_on_entity" to "service_role"; 55 + 56 + grant references on table "public"."poll_votes_on_entity" to "service_role"; 57 + 58 + grant select on table "public"."poll_votes_on_entity" to "service_role"; 59 + 60 + grant trigger on table "public"."poll_votes_on_entity" to "service_role"; 61 + 62 + grant truncate on table "public"."poll_votes_on_entity" to "service_role"; 63 + 64 + grant update on table "public"."poll_votes_on_entity" to "service_role";