diff --git a/app/[doc_id]/Blocks.tsx b/app/[doc_id]/Blocks.tsx index 03fc02b9..b1549c4e 100644 --- a/app/[doc_id]/Blocks.tsx +++ b/app/[doc_id]/Blocks.tsx @@ -1,7 +1,9 @@ "use client"; import { useEntity, useReplicache } from "../../replicache"; +import NextImage from "next/image"; import { TextBlock } from "../../components/TextBlock"; import { generateKeyBetween } from "fractional-indexing"; +import { supabaseBrowserClient } from "../../supabase/browserClient"; export function AddBlock(props: { entityID: string }) { let rep = useReplicache(); let blocks = useEntity(props.entityID, "card/block")?.sort((a, b) => { @@ -22,6 +24,78 @@ export function AddBlock(props: { entityID: string }) { ); } +export function AddImageBlock(props: { entityID: string }) { + let rep = useReplicache(); + let blocks = useEntity(props.entityID, "card/block")?.sort((a, b) => { + return a.data.position > b.data.position ? 1 : -1; + }); + return ( + { + let file = e.currentTarget.files?.[0]; + if (!file) return; + let client = supabaseBrowserClient(); + let cache = await caches.open("minilink-user-assets"); + let hash = await computeHash(file); + let url = client.storage.from("minilink-user-assets").getPublicUrl(hash) + .data.publicUrl; + let dimensions = await getImageDimensions(file); + await cache.put( + url, + new Response(file, { + headers: { + "Content-Type": file.type, + "Content-Length": file.size.toString(), + }, + }), + ); + let newBlockEntity = crypto.randomUUID(); + await rep?.rep?.mutate.addBlock({ + parent: props.entityID, + position: generateKeyBetween(null, blocks[0]?.data.position || null), + newEntityID: newBlockEntity, + }); + await rep?.rep?.mutate.assertFact({ + entity: newBlockEntity, + attribute: "block/image", + data: { + type: "image", + src: url, + height: dimensions.height, + width: dimensions.width, + }, + }); + await client.storage.from("minilink-user-assets").upload(hash, file); + }} + /> + ); +} + +function getImageDimensions( + file: File, +): Promise<{ width: number; height: number }> { + let url = URL.createObjectURL(file); + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = function () { + resolve({ width: img.width, height: img.height }); + URL.revokeObjectURL(url); + }; + img.onerror = reject; + img.src = url; + }); +} + +async function computeHash(data: File): Promise { + let buffer = await data.arrayBuffer(); + const buf = await crypto.subtle.digest("SHA-256", new Uint8Array(buffer)); + return Array.from(new Uint8Array(buf), (b) => + b.toString(16).padStart(2, "0"), + ).join(""); +} + export function Blocks(props: { entityID: string }) { let blocks = useEntity(props.entityID, "card/block"); @@ -54,6 +128,18 @@ function Block(props: { previousBlock: { position: string; value: string } | null; nextPosition: string | null; }) { + let image = useEntity(props.entityID, "block/image"); + if (image) + return ( +
+ {""} +
+ ); return (
diff --git a/app/[doc_id]/page.tsx b/app/[doc_id]/page.tsx index ed5f8c45..154cc15c 100644 --- a/app/[doc_id]/page.tsx +++ b/app/[doc_id]/page.tsx @@ -1,15 +1,16 @@ -import { createClient } from "@supabase/supabase-js"; import { Fact, ReplicacheProvider } from "../../replicache"; import { Database } from "../../supabase/database.types"; -import { AddBlock, Blocks } from "./Blocks"; +import { AddBlock, AddImageBlock, Blocks } from "./Blocks"; import { Attributes } from "../../replicache/attributes"; +import { createServerClient } from "@supabase/ssr"; export const preferredRegion = ["sfo1"]; export const dynamic = "force-dynamic"; -let supabase = createClient( +let supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, process.env.SUPABASE_SERVICE_ROLE_KEY as string, + { cookies: {} }, ); export default async function DocumentPage(props: { params: { doc_id: string }; @@ -20,6 +21,7 @@ export default async function DocumentPage(props: {
doc_id: {props.params.doc_id}
+
); diff --git a/app/layout.tsx b/app/layout.tsx index 2b5daa49..d254ef24 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,5 @@ import { InitialPageLoad } from "../components/InitialPageLoadProvider"; +import { ServiceWorker } from "../components/ServiceWorker"; import "./globals.css"; import localFont from "next/font/local"; @@ -23,6 +24,7 @@ export default function RootLayout({ return ( + {children} diff --git a/components/ServiceWorker.tsx b/components/ServiceWorker.tsx new file mode 100644 index 00000000..122486ff --- /dev/null +++ b/components/ServiceWorker.tsx @@ -0,0 +1,11 @@ +"use client"; +import { useEffect } from "react"; + +export function ServiceWorker() { + useEffect(() => { + if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("/worker.js"); + } + }, []); + return null; +} diff --git a/package-lock.json b/package-lock.json index 5ecf2ee2..74fedd81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@react-stately/color": "^3.6.0", "@spectrum-css/colorarea": "^5.1.0", "@spectrum-css/colorhandle": "^8.1.0", + "@supabase/ssr": "^0.3.0", "@supabase/supabase-js": "^2.43.2", "@vercel/kv": "^1.0.1", "base64-js": "^1.5.1", @@ -5874,6 +5875,18 @@ "ws": "^8.14.2" } }, + "node_modules/@supabase/ssr": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.3.0.tgz", + "integrity": "sha512-lcVyQ7H6eumb2FB1Wa2N+jYWMfq6CFza3KapikT0fgttMQ+QvDgpNogx9jI8bZgKds+XFSMCojxFvFb+gwdbfA==", + "dependencies": { + "cookie": "^0.5.0", + "ramda": "^0.29.0" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.33.1" + } + }, "node_modules/@supabase/storage-js": { "version": "2.5.5", "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.5.5.tgz", @@ -6958,7 +6971,6 @@ "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -11096,6 +11108,15 @@ } ] }, + "node_modules/ramda": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.1.tgz", + "integrity": "sha512-OfxIeWzd4xdUNxlWhgFazxsA/nl3mS4/jGZI5n00uWOoSSFRhC1b6gl6xvmzUamgmqELraWp0J/qqVlXYPDPyA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ramda" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", diff --git a/package.json b/package.json index e51a4bf4..f13039a2 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@react-stately/color": "^3.6.0", "@spectrum-css/colorarea": "^5.1.0", "@spectrum-css/colorhandle": "^8.1.0", + "@supabase/ssr": "^0.3.0", "@supabase/supabase-js": "^2.43.2", "@vercel/kv": "^1.0.1", "base64-js": "^1.5.1", diff --git a/public/worker.js b/public/worker.js new file mode 100644 index 00000000..68d18254 --- /dev/null +++ b/public/worker.js @@ -0,0 +1,19 @@ +self.addEventListener("fetch", (event) => { + console.log("Handling fetch event for", event.request.url); + + event.respondWith( + caches.open("minilink-user-assets").then(async (cache) => { + return cache + .match(event.request) + .then((response) => { + if (response) { + return response; + } + return fetch(event.request.clone()); + }) + .catch((error) => { + throw error; + }); + }), + ); +}); diff --git a/replicache/attributes.ts b/replicache/attributes.ts index 5d048a26..45ff184d 100644 --- a/replicache/attributes.ts +++ b/replicache/attributes.ts @@ -11,6 +11,10 @@ export const Attributes = { type: "text", cardinality: "one", }, + "block/image": { + type: "image", + cardinality: "one", + }, "block/card": { type: "reference", cardinality: "one", diff --git a/replicache/index.tsx b/replicache/index.tsx index 5b712e18..f5fe8973 100644 --- a/replicache/index.tsx +++ b/replicache/index.tsx @@ -6,9 +6,8 @@ import { Pull } from "./pull"; import { mutations } from "./mutations"; import { Attributes } from "./attributes"; import { Push } from "./push"; -import { createClient } from "@supabase/supabase-js"; -import { Database } from "../supabase/database.types"; import { clientMutationContext } from "./clientMutationContext"; +import { supabaseBrowserClient } from "../supabase/browserClient"; export type Fact = { id: string; @@ -24,6 +23,7 @@ type Data = { position: string; value: string; }; + image: { type: "image"; src: string; height: number; width: number }; reference: { type: "reference"; value: string }; }[(typeof Attributes)[A]["type"]]; @@ -47,10 +47,7 @@ export function ReplicacheProvider(props: { }) { let [rep, setRep] = useState>(null); useEffect(() => { - let supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY as string, - ); + let supabase = supabaseBrowserClient(); let newRep = new Replicache({ pushDelay: 500, mutators: Object.fromEntries( diff --git a/supabase/browserClient.ts b/supabase/browserClient.ts new file mode 100644 index 00000000..db10e265 --- /dev/null +++ b/supabase/browserClient.ts @@ -0,0 +1,9 @@ +import { createBrowserClient } from "@supabase/ssr"; +import { Database } from "./database.types"; + +export function supabaseBrowserClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_API_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + ); +}