a tool for shared writing and social publishing

Merge branch 'main' into feature/atp-canvas-blocks

+544 -85
+19 -3
app/lish/Subscribe.tsx
··· 23 23 import { useSearchParams } from "next/navigation"; 24 24 import LoginForm from "app/login/LoginForm"; 25 25 import { RSSSmall } from "components/Icons/RSSSmall"; 26 + import { SpeedyLink } from "components/SpeedyLink"; 26 27 27 28 type State = 28 29 | { state: "email" } ··· 217 218 pub_uri={props.pub_uri} 218 219 setSuccessModalOpen={setSuccessModalOpen} 219 220 /> 220 - <a href={`${props.base_url}/rss`} className="flex" target="_blank" aria-label="Subscribe to RSS"> 221 + <a 222 + href={`${props.base_url}/rss`} 223 + className="flex" 224 + target="_blank" 225 + aria-label="Subscribe to RSS" 226 + > 221 227 <RSSSmall className="self-center" aria-hidden /> 222 228 </a> 223 229 </div> ··· 246 252 className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`} 247 253 > 248 254 <div className="font-bold text-tertiary text-sm"> 249 - You&apos;re Subscribed{props.isPost ? ` to ${props.pubName}` : "!"} 255 + You&apos;re Subscribed{props.isPost ? ` to ` : "!"} 256 + {props.isPost && ( 257 + <SpeedyLink href={props.base_url} className="text-accent-contrast"> 258 + {props.pubName} 259 + </SpeedyLink> 260 + )} 250 261 </div> 251 262 <Popover 252 263 trigger={<div className="text-accent-contrast text-sm">Manage</div>} ··· 266 277 </a> 267 278 )} 268 279 269 - <a href={`${props.base_url}/rss`} className="flex" target="_blank" aria-label="Subscribe to RSS"> 280 + <a 281 + href={`${props.base_url}/rss`} 282 + className="flex" 283 + target="_blank" 284 + aria-label="Subscribe to RSS" 285 + > 270 286 <ButtonPrimary fullWidth compact> 271 287 Get RSS 272 288 </ButtonPrimary>
+19 -1
components/Blocks/Block.tsx
··· 7 7 import { useBlockKeyboardHandlers } from "./useBlockKeyboardHandlers"; 8 8 import { useLongPress } from "src/hooks/useLongPress"; 9 9 import { focusBlock } from "src/utils/focusBlock"; 10 + import { useHandleDrop } from "./useHandleDrop"; 11 + import { useEntitySetContext } from "components/EntitySetProvider"; 10 12 11 13 import { TextBlock } from "components/Blocks/TextBlock"; 12 14 import { ImageBlock } from "./ImageBlock"; ··· 15 17 import { EmbedBlock } from "./EmbedBlock"; 16 18 import { MailboxBlock } from "./MailboxBlock"; 17 19 import { AreYouSure } from "./DeleteBlock"; 18 - import { useEntitySetContext } from "components/EntitySetProvider"; 19 20 import { useIsMobile } from "src/hooks/isMobile"; 20 21 import { DateTimeBlock } from "./DateTimeBlock"; 21 22 import { RSVPBlock } from "./RSVPBlock"; ··· 63 64 // and shared styling like padding and flex for list layouting 64 65 65 66 let mouseHandlers = useBlockMouseHandlers(props); 67 + let handleDrop = useHandleDrop({ 68 + parent: props.parent, 69 + position: props.position, 70 + nextPosition: props.nextPosition, 71 + }); 72 + let entity_set = useEntitySetContext(); 66 73 67 74 let { isLongPress, handlers } = useLongPress(() => { 68 75 if (isTextBlock[props.type]) return; ··· 93 100 {...(!props.preview ? { ...mouseHandlers, ...handlers } : {})} 94 101 id={ 95 102 !props.preview ? elementId.block(props.entityID).container : undefined 103 + } 104 + onDragOver={ 105 + !props.preview && entity_set.permissions.write 106 + ? (e) => { 107 + e.preventDefault(); 108 + e.stopPropagation(); 109 + } 110 + : undefined 111 + } 112 + onDrop={ 113 + !props.preview && entity_set.permissions.write ? handleDrop : undefined 96 114 } 97 115 className={` 98 116 blockWrapper relative
+46 -25
components/Blocks/ImageBlock.tsx
··· 51 51 } 52 52 }, [isSelected, props.preview, props.entityID]); 53 53 54 + const handleImageUpload = async (file: File) => { 55 + if (!rep) return; 56 + let entity = props.entityID; 57 + if (!entity) { 58 + entity = v7(); 59 + await rep?.mutate.addBlock({ 60 + parent: props.parent, 61 + factID: v7(), 62 + permission_set: entity_set.set, 63 + type: "text", 64 + position: generateKeyBetween( 65 + props.position, 66 + props.nextPosition, 67 + ), 68 + newEntityID: entity, 69 + }); 70 + } 71 + await rep.mutate.assertFact({ 72 + entity, 73 + attribute: "block/type", 74 + data: { type: "block-type-union", value: "image" }, 75 + }); 76 + await addImage(file, rep, { 77 + entityID: entity, 78 + attribute: "block/image", 79 + }); 80 + }; 81 + 54 82 if (!image) { 55 83 if (!entity_set.permissions.write) return null; 56 84 return ( ··· 65 93 ${isSelected && !isLocked ? "border-2 border-tertiary font-bold" : "border border-border"} 66 94 ${props.pageType === "canvas" && "bg-bg-page"}`} 67 95 onMouseDown={(e) => e.preventDefault()} 96 + onDragOver={(e) => { 97 + e.preventDefault(); 98 + e.stopPropagation(); 99 + }} 100 + onDrop={async (e) => { 101 + e.preventDefault(); 102 + e.stopPropagation(); 103 + if (isLocked) return; 104 + const files = e.dataTransfer.files; 105 + if (files && files.length > 0) { 106 + const file = files[0]; 107 + if (file.type.startsWith('image/')) { 108 + await handleImageUpload(file); 109 + } 110 + } 111 + }} 68 112 > 69 113 <div className="flex gap-2"> 70 114 <BlockImageSmall ··· 79 123 accept="image/*" 80 124 onChange={async (e) => { 81 125 let file = e.currentTarget.files?.[0]; 82 - if (!file || !rep) return; 83 - let entity = props.entityID; 84 - if (!entity) { 85 - entity = v7(); 86 - await rep?.mutate.addBlock({ 87 - parent: props.parent, 88 - factID: v7(), 89 - permission_set: entity_set.set, 90 - type: "text", 91 - position: generateKeyBetween( 92 - props.position, 93 - props.nextPosition, 94 - ), 95 - newEntityID: entity, 96 - }); 97 - } 98 - await rep.mutate.assertFact({ 99 - entity, 100 - attribute: "block/type", 101 - data: { type: "block-type-union", value: "image" }, 102 - }); 103 - await addImage(file, rep, { 104 - entityID: entity, 105 - attribute: "block/image", 106 - }); 126 + if (!file) return; 127 + await handleImageUpload(file); 107 128 }} 108 129 /> 109 130 </label>
+11
components/Blocks/index.tsx
··· 17 17 import { useEffect } from "react"; 18 18 import { addShortcut } from "src/shortcuts"; 19 19 import { QuoteEmbedBlock } from "./QuoteEmbedBlock"; 20 + import { useHandleDrop } from "./useHandleDrop"; 20 21 21 22 export function Blocks(props: { entityID: string }) { 22 23 let rep = useReplicache(); ··· 231 232 }) => { 232 233 let { rep } = useReplicache(); 233 234 let entity_set = useEntitySetContext(); 235 + let handleDrop = useHandleDrop({ 236 + parent: props.entityID, 237 + position: props.lastRootBlock?.position || null, 238 + nextPosition: null, 239 + }); 234 240 235 241 if (!entity_set.permissions.write) return; 236 242 return ( ··· 267 273 }, 10); 268 274 } 269 275 }} 276 + onDragOver={(e) => { 277 + e.preventDefault(); 278 + e.stopPropagation(); 279 + }} 280 + onDrop={handleDrop} 270 281 /> 271 282 ); 272 283 };
+233
components/Blocks/useHandleCanvasDrop.ts
··· 1 + import { useCallback } from "react"; 2 + import { useReplicache, useEntity } from "src/replicache"; 3 + import { useEntitySetContext } from "components/EntitySetProvider"; 4 + import { v7 } from "uuid"; 5 + import { supabaseBrowserClient } from "supabase/browserClient"; 6 + import { localImages } from "src/utils/addImage"; 7 + import { rgbaToThumbHash, thumbHashToDataURL } from "thumbhash"; 8 + 9 + // Helper function to load image dimensions and thumbhash 10 + const processImage = async ( 11 + file: File, 12 + ): Promise<{ 13 + width: number; 14 + height: number; 15 + thumbhash: string; 16 + }> => { 17 + // Load image to get dimensions 18 + const img = new Image(); 19 + const url = URL.createObjectURL(file); 20 + 21 + const dimensions = await new Promise<{ width: number; height: number }>( 22 + (resolve, reject) => { 23 + img.onload = () => { 24 + resolve({ width: img.width, height: img.height }); 25 + }; 26 + img.onerror = reject; 27 + img.src = url; 28 + }, 29 + ); 30 + 31 + // Generate thumbhash 32 + const arrayBuffer = await file.arrayBuffer(); 33 + const blob = new Blob([arrayBuffer], { type: file.type }); 34 + const imageBitmap = await createImageBitmap(blob); 35 + 36 + const canvas = document.createElement("canvas"); 37 + const context = canvas.getContext("2d") as CanvasRenderingContext2D; 38 + const maxDimension = 100; 39 + let width = imageBitmap.width; 40 + let height = imageBitmap.height; 41 + 42 + if (width > height) { 43 + if (width > maxDimension) { 44 + height *= maxDimension / width; 45 + width = maxDimension; 46 + } 47 + } else { 48 + if (height > maxDimension) { 49 + width *= maxDimension / height; 50 + height = maxDimension; 51 + } 52 + } 53 + 54 + canvas.width = width; 55 + canvas.height = height; 56 + context.drawImage(imageBitmap, 0, 0, width, height); 57 + 58 + const imageData = context.getImageData(0, 0, width, height); 59 + const thumbhash = thumbHashToDataURL( 60 + rgbaToThumbHash(imageData.width, imageData.height, imageData.data), 61 + ); 62 + 63 + URL.revokeObjectURL(url); 64 + 65 + return { 66 + width: dimensions.width, 67 + height: dimensions.height, 68 + thumbhash, 69 + }; 70 + }; 71 + 72 + export const useHandleCanvasDrop = (entityID: string) => { 73 + let { rep } = useReplicache(); 74 + let entity_set = useEntitySetContext(); 75 + let blocks = useEntity(entityID, "canvas/block"); 76 + 77 + return useCallback( 78 + async (e: React.DragEvent) => { 79 + e.preventDefault(); 80 + e.stopPropagation(); 81 + 82 + if (!rep) return; 83 + 84 + const files = e.dataTransfer.files; 85 + if (!files || files.length === 0) return; 86 + 87 + // Filter for image files only 88 + const imageFiles = Array.from(files).filter((file) => 89 + file.type.startsWith("image/"), 90 + ); 91 + 92 + if (imageFiles.length === 0) return; 93 + 94 + const parentRect = e.currentTarget.getBoundingClientRect(); 95 + const dropX = Math.max(e.clientX - parentRect.left, 0); 96 + const dropY = Math.max(e.clientY - parentRect.top, 0); 97 + 98 + const SPACING = 0; 99 + const DEFAULT_WIDTH = 360; 100 + 101 + // Process all images to get dimensions and thumbhashes 102 + const processedImages = await Promise.all( 103 + imageFiles.map((file) => processImage(file)), 104 + ); 105 + 106 + // Calculate grid dimensions based on image count 107 + const COLUMNS = Math.ceil(Math.sqrt(imageFiles.length)); 108 + 109 + // Calculate the width and height for each column and row 110 + const colWidths: number[] = []; 111 + const rowHeights: number[] = []; 112 + 113 + for (let i = 0; i < imageFiles.length; i++) { 114 + const col = i % COLUMNS; 115 + const row = Math.floor(i / COLUMNS); 116 + const dims = processedImages[i]; 117 + 118 + // Scale image to fit within DEFAULT_WIDTH while maintaining aspect ratio 119 + const scale = DEFAULT_WIDTH / dims.width; 120 + const scaledWidth = DEFAULT_WIDTH; 121 + const scaledHeight = dims.height * scale; 122 + 123 + // Track max width for each column and max height for each row 124 + colWidths[col] = Math.max(colWidths[col] || 0, scaledWidth); 125 + rowHeights[row] = Math.max(rowHeights[row] || 0, scaledHeight); 126 + } 127 + 128 + const client = supabaseBrowserClient(); 129 + const cache = await caches.open("minilink-user-assets"); 130 + 131 + // Calculate positions and prepare data for all images 132 + const imageBlocks = imageFiles.map((file, index) => { 133 + const entity = v7(); 134 + const fileID = v7(); 135 + const row = Math.floor(index / COLUMNS); 136 + const col = index % COLUMNS; 137 + 138 + // Calculate x position by summing all previous column widths 139 + let x = dropX; 140 + for (let c = 0; c < col; c++) { 141 + x += colWidths[c] + SPACING; 142 + } 143 + 144 + // Calculate y position by summing all previous row heights 145 + let y = dropY; 146 + for (let r = 0; r < row; r++) { 147 + y += rowHeights[r] + SPACING; 148 + } 149 + 150 + const url = client.storage 151 + .from("minilink-user-assets") 152 + .getPublicUrl(fileID).data.publicUrl; 153 + 154 + return { 155 + file, 156 + entity, 157 + fileID, 158 + url, 159 + position: { x, y }, 160 + dimensions: processedImages[index], 161 + }; 162 + }); 163 + 164 + // Create all blocks with image facts 165 + for (const block of imageBlocks) { 166 + // Add to cache for immediate display 167 + await cache.put( 168 + new URL(block.url + "?local"), 169 + new Response(block.file, { 170 + headers: { 171 + "Content-Type": block.file.type, 172 + "Content-Length": block.file.size.toString(), 173 + }, 174 + }), 175 + ); 176 + localImages.set(block.url, true); 177 + 178 + // Create canvas block 179 + await rep.mutate.addCanvasBlock({ 180 + newEntityID: block.entity, 181 + parent: entityID, 182 + position: block.position, 183 + factID: v7(), 184 + type: "image", 185 + permission_set: entity_set.set, 186 + }); 187 + 188 + // Add image fact with local version for immediate display 189 + if (navigator.serviceWorker) { 190 + await rep.mutate.assertFact({ 191 + entity: block.entity, 192 + attribute: "block/image", 193 + data: { 194 + fallback: block.dimensions.thumbhash, 195 + type: "image", 196 + local: rep.clientID, 197 + src: block.url, 198 + height: block.dimensions.height, 199 + width: block.dimensions.width, 200 + }, 201 + }); 202 + } 203 + } 204 + 205 + // Upload all files to storage in parallel 206 + await Promise.all( 207 + imageBlocks.map(async (block) => { 208 + await client.storage 209 + .from("minilink-user-assets") 210 + .upload(block.fileID, block.file, { 211 + cacheControl: "public, max-age=31560000, immutable", 212 + }); 213 + 214 + // Update fact with final version 215 + await rep.mutate.assertFact({ 216 + entity: block.entity, 217 + attribute: "block/image", 218 + data: { 219 + fallback: block.dimensions.thumbhash, 220 + type: "image", 221 + src: block.url, 222 + height: block.dimensions.height, 223 + width: block.dimensions.width, 224 + }, 225 + }); 226 + }), 227 + ); 228 + 229 + return true; 230 + }, 231 + [rep, entityID, entity_set.set, blocks], 232 + ); 233 + };
+74
components/Blocks/useHandleDrop.ts
··· 1 + import { useCallback } from "react"; 2 + import { useReplicache } from "src/replicache"; 3 + import { generateKeyBetween } from "fractional-indexing"; 4 + import { addImage } from "src/utils/addImage"; 5 + import { useEntitySetContext } from "components/EntitySetProvider"; 6 + import { v7 } from "uuid"; 7 + 8 + export const useHandleDrop = (params: { 9 + parent: string; 10 + position: string | null; 11 + nextPosition: string | null; 12 + }) => { 13 + let { rep } = useReplicache(); 14 + let entity_set = useEntitySetContext(); 15 + 16 + return useCallback( 17 + async (e: React.DragEvent) => { 18 + e.preventDefault(); 19 + e.stopPropagation(); 20 + 21 + if (!rep) return; 22 + 23 + const files = e.dataTransfer.files; 24 + if (!files || files.length === 0) return; 25 + 26 + // Filter for image files only 27 + const imageFiles = Array.from(files).filter((file) => 28 + file.type.startsWith("image/"), 29 + ); 30 + 31 + if (imageFiles.length === 0) return; 32 + 33 + let currentPosition = params.position; 34 + 35 + // Calculate positions for all images first 36 + const imageBlocks = imageFiles.map((file) => { 37 + const entity = v7(); 38 + const position = generateKeyBetween( 39 + currentPosition, 40 + params.nextPosition, 41 + ); 42 + currentPosition = position; 43 + return { file, entity, position }; 44 + }); 45 + 46 + // Create all blocks in parallel 47 + await Promise.all( 48 + imageBlocks.map((block) => 49 + rep.mutate.addBlock({ 50 + parent: params.parent, 51 + factID: v7(), 52 + permission_set: entity_set.set, 53 + type: "image", 54 + position: block.position, 55 + newEntityID: block.entity, 56 + }), 57 + ), 58 + ); 59 + 60 + // Upload all images in parallel 61 + await Promise.all( 62 + imageBlocks.map((block) => 63 + addImage(block.file, rep, { 64 + entityID: block.entity, 65 + attribute: "block/image", 66 + }), 67 + ), 68 + ); 69 + 70 + return true; 71 + }, 72 + [rep, params.position, params.nextPosition, params.parent, entity_set.set], 73 + ); 74 + };
+14 -1
components/Canvas.tsx
··· 25 25 PubLeafletPublication, 26 26 PubLeafletPublicationRecord, 27 27 } from "lexicons/api"; 28 - import { getCommentCount } from "app/lish/[did]/[publication]/[rkey]/Interactions/Interactions"; 28 + import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 29 29 30 30 export function Canvas(props: { 31 31 entityID: string; ··· 84 84 let { rep } = useReplicache(); 85 85 let entity_set = useEntitySetContext(); 86 86 let height = Math.max(...blocks.map((f) => f.data.position.y), 0); 87 + let handleDrop = useHandleCanvasDrop(props.entityID); 88 + 87 89 return ( 88 90 <div 89 91 onClick={async (e) => { ··· 121 123 ); 122 124 } 123 125 }} 126 + onDragOver={ 127 + !props.preview && entity_set.permissions.write 128 + ? (e) => { 129 + e.preventDefault(); 130 + e.stopPropagation(); 131 + } 132 + : undefined 133 + } 134 + onDrop={ 135 + !props.preview && entity_set.permissions.write ? handleDrop : undefined 136 + } 124 137 style={{ 125 138 minHeight: height + 512, 126 139 contain: "size layout paint",
+2
components/Pages/Page.tsx
··· 34 34 }); 35 35 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 36 36 let cardBorderHidden = useCardBorderHidden(props.entityID); 37 + 37 38 let drawerOpen = useDrawerOpen(props.entityID); 38 39 return ( 39 40 <CardThemeProvider entityID={props.entityID}> ··· 125 126 `} 126 127 > 127 128 {props.children} 129 + {props.pageType === "doc" && <div className="h-4 sm:h-6 w-full" />} 128 130 </div> 129 131 </div> 130 132 {props.pageOptions}
+3 -55
src/utils/scrollIntoView.ts
··· 1 - // Generated with claude code, sonnet 4.5 2 - /** 3 - * Scrolls an element into view within a scrolling container using Intersection Observer 4 - * and the scrollTo API, instead of the native scrollIntoView. 5 - * 6 - * @param elementId - The ID of the element to scroll into view 7 - * @param scrollContainerId - The ID of the scrolling container (defaults to "pages") 8 - * @param threshold - Intersection observer threshold (0-1, defaults to 0.2 for 20%) 9 - */ 1 + import { scrollIntoViewIfNeeded } from "./scrollIntoViewIfNeeded"; 2 + 10 3 export function scrollIntoView( 11 4 elementId: string, 12 5 scrollContainerId: string = "pages", 13 6 threshold: number = 0.9, 14 7 ) { 15 8 const element = document.getElementById(elementId); 16 - const scrollContainer = document.getElementById(scrollContainerId); 17 - 18 - if (!element || !scrollContainer) { 19 - console.warn(`scrollIntoView: element or container not found`, { 20 - elementId, 21 - scrollContainerId, 22 - element, 23 - scrollContainer, 24 - }); 25 - return; 26 - } 27 - 28 - // Create an intersection observer to check if element is visible 29 - const observer = new IntersectionObserver( 30 - (entries) => { 31 - const entry = entries[0]; 32 - 33 - // If element is not sufficiently visible, scroll to it 34 - if (!entry.isIntersecting || entry.intersectionRatio < threshold) { 35 - const elementRect = element.getBoundingClientRect(); 36 - const containerRect = scrollContainer.getBoundingClientRect(); 37 - 38 - // Calculate the target scroll position 39 - // We want to center the element horizontally in the container 40 - const targetScrollLeft = 41 - scrollContainer.scrollLeft + 42 - elementRect.left - 43 - containerRect.left - 44 - (containerRect.width - elementRect.width) / 2; 45 - 46 - scrollContainer.scrollTo({ 47 - left: targetScrollLeft, 48 - behavior: "smooth", 49 - }); 50 - } 51 - 52 - // Disconnect after checking once 53 - observer.disconnect(); 54 - }, 55 - { 56 - root: scrollContainer, 57 - threshold: threshold, 58 - }, 59 - ); 60 - 61 - observer.observe(element); 9 + scrollIntoViewIfNeeded(element, false, "smooth"); 62 10 }
+123
supabase/migrations/20251023200453_atp_poll_votes.sql
··· 1 + create table "public"."atp_poll_votes" ( 2 + "uri" text not null, 3 + "record" jsonb not null, 4 + "voter_did" text not null, 5 + "poll_uri" text not null, 6 + "poll_cid" text not null, 7 + "option" text not null, 8 + "indexed_at" timestamp with time zone not null default now() 9 + ); 10 + 11 + alter table "public"."atp_poll_votes" enable row level security; 12 + 13 + CREATE UNIQUE INDEX atp_poll_votes_pkey ON public.atp_poll_votes USING btree (uri); 14 + 15 + alter table "public"."atp_poll_votes" add constraint "atp_poll_votes_pkey" PRIMARY KEY using index "atp_poll_votes_pkey"; 16 + 17 + CREATE INDEX atp_poll_votes_poll_uri_idx ON public.atp_poll_votes USING btree (poll_uri); 18 + 19 + CREATE INDEX atp_poll_votes_voter_did_idx ON public.atp_poll_votes USING btree (voter_did); 20 + 21 + grant delete on table "public"."atp_poll_votes" to "anon"; 22 + 23 + grant insert on table "public"."atp_poll_votes" to "anon"; 24 + 25 + grant references on table "public"."atp_poll_votes" to "anon"; 26 + 27 + grant select on table "public"."atp_poll_votes" to "anon"; 28 + 29 + grant trigger on table "public"."atp_poll_votes" to "anon"; 30 + 31 + grant truncate on table "public"."atp_poll_votes" to "anon"; 32 + 33 + grant update on table "public"."atp_poll_votes" to "anon"; 34 + 35 + grant delete on table "public"."atp_poll_votes" to "authenticated"; 36 + 37 + grant insert on table "public"."atp_poll_votes" to "authenticated"; 38 + 39 + grant references on table "public"."atp_poll_votes" to "authenticated"; 40 + 41 + grant select on table "public"."atp_poll_votes" to "authenticated"; 42 + 43 + grant trigger on table "public"."atp_poll_votes" to "authenticated"; 44 + 45 + grant truncate on table "public"."atp_poll_votes" to "authenticated"; 46 + 47 + grant update on table "public"."atp_poll_votes" to "authenticated"; 48 + 49 + grant delete on table "public"."atp_poll_votes" to "service_role"; 50 + 51 + grant insert on table "public"."atp_poll_votes" to "service_role"; 52 + 53 + grant references on table "public"."atp_poll_votes" to "service_role"; 54 + 55 + grant select on table "public"."atp_poll_votes" to "service_role"; 56 + 57 + grant trigger on table "public"."atp_poll_votes" to "service_role"; 58 + 59 + grant truncate on table "public"."atp_poll_votes" to "service_role"; 60 + 61 + grant update on table "public"."atp_poll_votes" to "service_role"; 62 + 63 + create table "public"."atp_poll_records" ( 64 + "uri" text not null, 65 + "cid" text not null, 66 + "record" jsonb not null, 67 + "created_at" timestamp with time zone not null default now() 68 + ); 69 + 70 + 71 + alter table "public"."atp_poll_records" enable row level security; 72 + 73 + alter table "public"."bsky_follows" alter column "identity" set default ''::text; 74 + 75 + CREATE UNIQUE INDEX atp_poll_records_pkey ON public.atp_poll_records USING btree (uri); 76 + 77 + alter table "public"."atp_poll_records" add constraint "atp_poll_records_pkey" PRIMARY KEY using index "atp_poll_records_pkey"; 78 + 79 + alter table "public"."atp_poll_votes" add constraint "atp_poll_votes_poll_uri_fkey" FOREIGN KEY (poll_uri) REFERENCES atp_poll_records(uri) ON UPDATE CASCADE ON DELETE CASCADE not valid; 80 + 81 + alter table "public"."atp_poll_votes" validate constraint "atp_poll_votes_poll_uri_fkey"; 82 + 83 + grant delete on table "public"."atp_poll_records" to "anon"; 84 + 85 + grant insert on table "public"."atp_poll_records" to "anon"; 86 + 87 + grant references on table "public"."atp_poll_records" to "anon"; 88 + 89 + grant select on table "public"."atp_poll_records" to "anon"; 90 + 91 + grant trigger on table "public"."atp_poll_records" to "anon"; 92 + 93 + grant truncate on table "public"."atp_poll_records" to "anon"; 94 + 95 + grant update on table "public"."atp_poll_records" to "anon"; 96 + 97 + grant delete on table "public"."atp_poll_records" to "authenticated"; 98 + 99 + grant insert on table "public"."atp_poll_records" to "authenticated"; 100 + 101 + grant references on table "public"."atp_poll_records" to "authenticated"; 102 + 103 + grant select on table "public"."atp_poll_records" to "authenticated"; 104 + 105 + grant trigger on table "public"."atp_poll_records" to "authenticated"; 106 + 107 + grant truncate on table "public"."atp_poll_records" to "authenticated"; 108 + 109 + grant update on table "public"."atp_poll_records" to "authenticated"; 110 + 111 + grant delete on table "public"."atp_poll_records" to "service_role"; 112 + 113 + grant insert on table "public"."atp_poll_records" to "service_role"; 114 + 115 + grant references on table "public"."atp_poll_records" to "service_role"; 116 + 117 + grant select on table "public"."atp_poll_records" to "service_role"; 118 + 119 + grant trigger on table "public"."atp_poll_records" to "service_role"; 120 + 121 + grant truncate on table "public"."atp_poll_records" to "service_role"; 122 + 123 + grant update on table "public"."atp_poll_records" to "service_role";