a tool for shared writing and social publishing

add basic image block handling

+86
app/[doc_id]/Blocks.tsx
··· 1 1 "use client"; 2 2 import { useEntity, useReplicache } from "../../replicache"; 3 + import NextImage from "next/image"; 3 4 import { TextBlock } from "../../components/TextBlock"; 4 5 import { generateKeyBetween } from "fractional-indexing"; 6 + import { supabaseBrowserClient } from "../../supabase/browserClient"; 5 7 export function AddBlock(props: { entityID: string }) { 6 8 let rep = useReplicache(); 7 9 let blocks = useEntity(props.entityID, "card/block")?.sort((a, b) => { ··· 22 24 ); 23 25 } 24 26 27 + export function AddImageBlock(props: { entityID: string }) { 28 + let rep = useReplicache(); 29 + let blocks = useEntity(props.entityID, "card/block")?.sort((a, b) => { 30 + return a.data.position > b.data.position ? 1 : -1; 31 + }); 32 + return ( 33 + <input 34 + type="file" 35 + accept="image/*" 36 + onChange={async (e) => { 37 + let file = e.currentTarget.files?.[0]; 38 + if (!file) return; 39 + let client = supabaseBrowserClient(); 40 + let cache = await caches.open("minilink-user-assets"); 41 + let hash = await computeHash(file); 42 + let url = client.storage.from("minilink-user-assets").getPublicUrl(hash) 43 + .data.publicUrl; 44 + let dimensions = await getImageDimensions(file); 45 + await cache.put( 46 + url, 47 + new Response(file, { 48 + headers: { 49 + "Content-Type": file.type, 50 + "Content-Length": file.size.toString(), 51 + }, 52 + }), 53 + ); 54 + let newBlockEntity = crypto.randomUUID(); 55 + await rep?.rep?.mutate.addBlock({ 56 + parent: props.entityID, 57 + position: generateKeyBetween(null, blocks[0]?.data.position || null), 58 + newEntityID: newBlockEntity, 59 + }); 60 + await rep?.rep?.mutate.assertFact({ 61 + entity: newBlockEntity, 62 + attribute: "block/image", 63 + data: { 64 + type: "image", 65 + src: url, 66 + height: dimensions.height, 67 + width: dimensions.width, 68 + }, 69 + }); 70 + await client.storage.from("minilink-user-assets").upload(hash, file); 71 + }} 72 + /> 73 + ); 74 + } 75 + 76 + function getImageDimensions( 77 + file: File, 78 + ): Promise<{ width: number; height: number }> { 79 + let url = URL.createObjectURL(file); 80 + return new Promise((resolve, reject) => { 81 + const img = new Image(); 82 + img.onload = function () { 83 + resolve({ width: img.width, height: img.height }); 84 + URL.revokeObjectURL(url); 85 + }; 86 + img.onerror = reject; 87 + img.src = url; 88 + }); 89 + } 90 + 91 + async function computeHash(data: File): Promise<string> { 92 + let buffer = await data.arrayBuffer(); 93 + const buf = await crypto.subtle.digest("SHA-256", new Uint8Array(buffer)); 94 + return Array.from(new Uint8Array(buf), (b) => 95 + b.toString(16).padStart(2, "0"), 96 + ).join(""); 97 + } 98 + 25 99 export function Blocks(props: { entityID: string }) { 26 100 let blocks = useEntity(props.entityID, "card/block"); 27 101 ··· 54 128 previousBlock: { position: string; value: string } | null; 55 129 nextPosition: string | null; 56 130 }) { 131 + let image = useEntity(props.entityID, "block/image"); 132 + if (image) 133 + return ( 134 + <div className="border p-2 w-full"> 135 + <img 136 + alt={""} 137 + src={image.data.src} 138 + height={image.data.height} 139 + width={image.data.width} 140 + /> 141 + </div> 142 + ); 57 143 return ( 58 144 <div className="border p-2 w-full"> 59 145 <TextBlock {...props} />
+5 -3
app/[doc_id]/page.tsx
··· 1 - import { createClient } from "@supabase/supabase-js"; 2 1 import { Fact, ReplicacheProvider } from "../../replicache"; 3 2 import { Database } from "../../supabase/database.types"; 4 - import { AddBlock, Blocks } from "./Blocks"; 3 + import { AddBlock, AddImageBlock, Blocks } from "./Blocks"; 5 4 import { Attributes } from "../../replicache/attributes"; 5 + import { createServerClient } from "@supabase/ssr"; 6 6 7 7 export const preferredRegion = ["sfo1"]; 8 8 export const dynamic = "force-dynamic"; 9 9 10 - let supabase = createClient<Database>( 10 + let supabase = createServerClient<Database>( 11 11 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 12 12 process.env.SUPABASE_SERVICE_ROLE_KEY as string, 13 + { cookies: {} }, 13 14 ); 14 15 export default async function DocumentPage(props: { 15 16 params: { doc_id: string }; ··· 20 21 <ReplicacheProvider name={props.params.doc_id} initialFacts={initialFacts}> 21 22 <div className="text-blue-400">doc_id: {props.params.doc_id}</div> 22 23 <AddBlock entityID={props.params.doc_id} /> 24 + <AddImageBlock entityID={props.params.doc_id} /> 23 25 <Blocks entityID={props.params.doc_id} /> 24 26 </ReplicacheProvider> 25 27 );
+2
app/layout.tsx
··· 1 1 import { InitialPageLoad } from "../components/InitialPageLoadProvider"; 2 + import { ServiceWorker } from "../components/ServiceWorker"; 2 3 import "./globals.css"; 3 4 import localFont from "next/font/local"; 4 5 ··· 23 24 return ( 24 25 <html lang="en" className={`${quattro.variable}`}> 25 26 <body> 27 + <ServiceWorker /> 26 28 <InitialPageLoad>{children}</InitialPageLoad> 27 29 </body> 28 30 </html>
+11
components/ServiceWorker.tsx
··· 1 + "use client"; 2 + import { useEffect } from "react"; 3 + 4 + export function ServiceWorker() { 5 + useEffect(() => { 6 + if ("serviceWorker" in navigator) { 7 + navigator.serviceWorker.register("/worker.js"); 8 + } 9 + }, []); 10 + return null; 11 + }
+22 -1
package-lock.json
··· 17 17 "@react-stately/color": "^3.6.0", 18 18 "@spectrum-css/colorarea": "^5.1.0", 19 19 "@spectrum-css/colorhandle": "^8.1.0", 20 + "@supabase/ssr": "^0.3.0", 20 21 "@supabase/supabase-js": "^2.43.2", 21 22 "@vercel/kv": "^1.0.1", 22 23 "base64-js": "^1.5.1", ··· 5874 5875 "ws": "^8.14.2" 5875 5876 } 5876 5877 }, 5878 + "node_modules/@supabase/ssr": { 5879 + "version": "0.3.0", 5880 + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.3.0.tgz", 5881 + "integrity": "sha512-lcVyQ7H6eumb2FB1Wa2N+jYWMfq6CFza3KapikT0fgttMQ+QvDgpNogx9jI8bZgKds+XFSMCojxFvFb+gwdbfA==", 5882 + "dependencies": { 5883 + "cookie": "^0.5.0", 5884 + "ramda": "^0.29.0" 5885 + }, 5886 + "peerDependencies": { 5887 + "@supabase/supabase-js": "^2.33.1" 5888 + } 5889 + }, 5877 5890 "node_modules/@supabase/storage-js": { 5878 5891 "version": "2.5.5", 5879 5892 "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.5.5.tgz", ··· 6958 6971 "version": "0.5.0", 6959 6972 "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", 6960 6973 "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", 6961 - "dev": true, 6962 6974 "engines": { 6963 6975 "node": ">= 0.6" 6964 6976 } ··· 11095 11107 "url": "https://feross.org/support" 11096 11108 } 11097 11109 ] 11110 + }, 11111 + "node_modules/ramda": { 11112 + "version": "0.29.1", 11113 + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.1.tgz", 11114 + "integrity": "sha512-OfxIeWzd4xdUNxlWhgFazxsA/nl3mS4/jGZI5n00uWOoSSFRhC1b6gl6xvmzUamgmqELraWp0J/qqVlXYPDPyA==", 11115 + "funding": { 11116 + "type": "opencollective", 11117 + "url": "https://opencollective.com/ramda" 11118 + } 11098 11119 }, 11099 11120 "node_modules/react": { 11100 11121 "version": "18.3.1",
+1
package.json
··· 19 19 "@react-stately/color": "^3.6.0", 20 20 "@spectrum-css/colorarea": "^5.1.0", 21 21 "@spectrum-css/colorhandle": "^8.1.0", 22 + "@supabase/ssr": "^0.3.0", 22 23 "@supabase/supabase-js": "^2.43.2", 23 24 "@vercel/kv": "^1.0.1", 24 25 "base64-js": "^1.5.1",
+19
public/worker.js
··· 1 + self.addEventListener("fetch", (event) => { 2 + console.log("Handling fetch event for", event.request.url); 3 + 4 + event.respondWith( 5 + caches.open("minilink-user-assets").then(async (cache) => { 6 + return cache 7 + .match(event.request) 8 + .then((response) => { 9 + if (response) { 10 + return response; 11 + } 12 + return fetch(event.request.clone()); 13 + }) 14 + .catch((error) => { 15 + throw error; 16 + }); 17 + }), 18 + ); 19 + });
+4
replicache/attributes.ts
··· 11 11 type: "text", 12 12 cardinality: "one", 13 13 }, 14 + "block/image": { 15 + type: "image", 16 + cardinality: "one", 17 + }, 14 18 "block/card": { 15 19 type: "reference", 16 20 cardinality: "one",
+3 -6
replicache/index.tsx
··· 6 6 import { mutations } from "./mutations"; 7 7 import { Attributes } from "./attributes"; 8 8 import { Push } from "./push"; 9 - import { createClient } from "@supabase/supabase-js"; 10 - import { Database } from "../supabase/database.types"; 11 9 import { clientMutationContext } from "./clientMutationContext"; 10 + import { supabaseBrowserClient } from "../supabase/browserClient"; 12 11 13 12 export type Fact<A extends keyof typeof Attributes> = { 14 13 id: string; ··· 24 23 position: string; 25 24 value: string; 26 25 }; 26 + image: { type: "image"; src: string; height: number; width: number }; 27 27 reference: { type: "reference"; value: string }; 28 28 }[(typeof Attributes)[A]["type"]]; 29 29 ··· 47 47 }) { 48 48 let [rep, setRep] = useState<null | Replicache<ReplicacheMutators>>(null); 49 49 useEffect(() => { 50 - let supabase = createClient<Database>( 51 - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 52 - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY as string, 53 - ); 50 + let supabase = supabaseBrowserClient(); 54 51 let newRep = new Replicache({ 55 52 pushDelay: 500, 56 53 mutators: Object.fromEntries(
+9
supabase/browserClient.ts
··· 1 + import { createBrowserClient } from "@supabase/ssr"; 2 + import { Database } from "./database.types"; 3 + 4 + export function supabaseBrowserClient() { 5 + return createBrowserClient<Database>( 6 + process.env.NEXT_PUBLIC_SUPABASE_API_URL!, 7 + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 8 + ); 9 + }