diff --git a/demos/react-supabase-pixel-canvas/.env.local.template b/demos/react-supabase-pixel-canvas/.env.local.template new file mode 100644 index 000000000..0da0e8d1b --- /dev/null +++ b/demos/react-supabase-pixel-canvas/.env.local.template @@ -0,0 +1,8 @@ +# Copy this file to `.env.local` to enable backend sync. +# +# When these are NOT set, the app runs fully standalone: the canvas is seeded +# locally in SQLite and pixels are never uploaded. Set all three to connect to +# Supabase + PowerSync and sync in real time. +VITE_SUPABASE_URL= +VITE_SUPABASE_ANON_KEY= +VITE_POWERSYNC_URL= diff --git a/demos/react-supabase-pixel-canvas/README.md b/demos/react-supabase-pixel-canvas/README.md new file mode 100644 index 000000000..4d3fd9e7a --- /dev/null +++ b/demos/react-supabase-pixel-canvas/README.md @@ -0,0 +1,103 @@ +# PowerSync Pixel Canvas + +A collaborative r/place-style pixel canvas built on [PowerSync](https://powersync.com) + Supabase, +designed as a conference booth demo. A big **booth** screen shows the shared 32×32 canvas and a QR +code; visitors scan it to open the **draw** page on their phones, place pixels, and watch changes +sync in real time — including offline queue + resume. + +## Pages + +- **`/` — Booth**: fullscreen canvas, live stats ticker (pixels placed + distinct artists), and a QR + code linking to `/draw`. +- **`/draw` — Draw**: mobile-first. Pick a colour, tap a cell to place a pixel, toggle the PowerSync + connection on/off, and see the pending upload queue. + +## Running standalone (no backend) + +The app works with **no backend configured**. In this mode the 32×32 canvas is seeded locally in +SQLite and pixels stay on-device (never uploaded). + +```bash +pnpm install +pnpm dev +``` + +Open the booth at http://localhost:5173/ and the draw page at http://localhost:5173/draw. + +To test from a phone on the same network, run `pnpm dev --host` and scan the QR code (it is built +from `window.location.origin`, so the booth machine's origin must be reachable from the phone). + +## Running with sync (Supabase Cloud + PowerSync Cloud) + +Two config files in this folder drive the backend: + +- [`database.sql`](./database.sql) — table, 1024-cell seed, RLS policies, and the `powersync` publication. +- [`sync-config.yaml`](./sync-config.yaml) — the PowerSync sync rules (one shared stream over all pixels). + +### 1. Supabase + +1. Create a project at [supabase.com](https://supabase.com). +2. In the **SQL editor**, paste and run [`database.sql`](./database.sql). This creates the `pixels` + table, seeds all 1024 cells white, enables RLS (read + update for the `authenticated` role, which + covers anonymous sessions), and creates the `powersync` publication. +3. Under **Authentication → Providers** (Project Settings), enable **Allow anonymous sign-ins** and + save. Each booth visitor becomes a distinct anonymous user. + +### 2. PowerSync + +1. Create an instance in the [PowerSync dashboard](https://powersync.journeyapps.com/). +2. **Connections** tab → add a database connection to your Supabase Postgres: paste the connection + string from Supabase (**Project Settings → Database**), using the **direct connection** (turn + connection pooling **off**), enter the DB password, and **Test connection**. +3. **Credentials** tab → tick **Use Supabase Auth** and paste your Supabase **JWT secret** (Supabase + → Project Settings → API → JWT Settings). This lets PowerSync validate the anon-user tokens the + client sends. +4. **Sync rules** → paste the contents of [`sync-config.yaml`](./sync-config.yaml) into the editor + and **Deploy**. + +### 3. Client env vars + +```bash +cp .env.local.template .env.local +``` + +Fill in all three: + +``` +VITE_SUPABASE_URL=https://.supabase.co +VITE_SUPABASE_ANON_KEY= +VITE_POWERSYNC_URL=https://.powersync.journeyapps.com +``` + +Then `pnpm dev`. With these set, the app **does not seed locally** — it signs in anonymously, +connects, and the 1024 cells arrive via sync. Open the booth on one device and `/draw` on another +(or several) and watch pixels sync in real time. + +> The client only ever issues `UPDATE`s (never inserts), which is why the server table must be +> pre-seeded by `database.sql`. A placed pixel is a PATCH against an existing `x:y` row, resolved +> last-write-wins. + +See the [PowerSync + Supabase integration guide](https://docs.powersync.com/integration-guides/supabase-+-powersync) +for more detail on the Cloud connection wizard. + +## Storage + +- **Safari (macOS/iOS)**: in-memory database (WebKit's OPFS support has been the flakiest). Note: + until `@powersync/web` ships `WASQLiteVFS.InMemoryVfs` (> 1.38.6) this falls back to the + WebKit-safe IndexedDB VFS automatically — no code change needed on upgrade. +- **Everything else**: persistent OPFS. + +## Admin (booth) + +- **⇧C** — clear the entire canvas (with confirmation). +- **⇧E** — export the current canvas as a 1024×1024 PNG. +- Add `?admin=1` to the URL to show these as on-screen buttons. + +Clearing while connected queues ~1024 PATCH uploads (one per cell) through the row-by-row connector, +which can take a minute or two to drain. For an instant reset, run the equivalent `UPDATE` in the +Supabase SQL editor. + +## Notes + +- "Pixels placed" counts cells currently coloured by a real user (not the seed), not a cumulative + event total — the schema has no event log by design. diff --git a/demos/react-supabase-pixel-canvas/database.sql b/demos/react-supabase-pixel-canvas/database.sql new file mode 100644 index 000000000..04ac7cf82 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/database.sql @@ -0,0 +1,45 @@ +-- PowerSync Pixel Canvas — Supabase setup +-- Run this in the Supabase SQL editor for your cloud project (or as a migration). + +-- 1. Table: one row per canvas cell. `id` is the deterministic "x:y" string the +-- client uses, so client UPDATEs (PATCH ops) target existing rows by id. +-- `id` must be text — PowerSync requires a text id column named `id`. +CREATE TABLE IF NOT EXISTS public.pixels ( + id text PRIMARY KEY, + x integer NOT NULL, + y integer NOT NULL, + color integer NOT NULL DEFAULT 0, + updated_by text, + updated_at text +); + +-- 2. Seed all 1024 cells (32x32) white (color 0). The client NEVER inserts — it +-- only UPDATEs pre-existing rows — so the canvas MUST be seeded here, or +-- placed pixels would update zero rows and silently fail to persist. +INSERT INTO public.pixels (id, x, y, color, updated_by, updated_at) +SELECT gx || ':' || gy, gx, gy, 0, 'seed', '' +FROM generate_series(0, 31) AS gx, + generate_series(0, 31) AS gy +ON CONFLICT (id) DO NOTHING; + +-- 3. Row-level security. Anonymous Supabase sessions carry the `authenticated` +-- role, so these policies cover booth visitors. The client only reads and +-- updates pixels (no insert/delete), so we grant exactly those two. +ALTER TABLE public.pixels ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Anyone can read pixels" ON public.pixels; +CREATE POLICY "Anyone can read pixels" + ON public.pixels FOR SELECT + TO authenticated + USING (true); + +DROP POLICY IF EXISTS "Anyone can paint pixels" ON public.pixels; +CREATE POLICY "Anyone can paint pixels" + ON public.pixels FOR UPDATE + TO authenticated + USING (true) + WITH CHECK (true); + +-- 4. Publication PowerSync replicates from. It must be named `powersync`. +DROP PUBLICATION IF EXISTS powersync; +CREATE PUBLICATION powersync FOR TABLE public.pixels; diff --git a/demos/react-supabase-pixel-canvas/package.json b/demos/react-supabase-pixel-canvas/package.json new file mode 100644 index 000000000..d9944aeb7 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/package.json @@ -0,0 +1,30 @@ +{ + "name": "react-supabase-pixel-canvas", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "start": "pnpm build && pnpm preview" + }, + "dependencies": { + "@journeyapps/wa-sqlite": "^1.7.0", + "@powersync/react": "^1.10.0", + "@powersync/web": "0.0.0-dev-20260708104358", + "@supabase/supabase-js": "^2.39.7", + "qrcode.react": "^4.2.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.3" + }, + "devDependencies": { + "@types/node": "^20.11.25", + "@types/react": "^18.2.64", + "@types/react-dom": "^18.2.21", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.4.2", + "vite": "^5.1.5", + "vite-plugin-pwa": "^1.3.0" + } +} diff --git a/demos/react-supabase-pixel-canvas/pnpm-workspace.yaml b/demos/react-supabase-pixel-canvas/pnpm-workspace.yaml new file mode 100644 index 000000000..85a821669 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +packages: + - . +allowBuilds: + '@journeyapps/wa-sqlite': true + '@swc/core': true + esbuild: true diff --git a/demos/react-supabase-pixel-canvas/public/favicon.ico b/demos/react-supabase-pixel-canvas/public/favicon.ico new file mode 100644 index 000000000..918ca54ee Binary files /dev/null and b/demos/react-supabase-pixel-canvas/public/favicon.ico differ diff --git a/demos/react-supabase-pixel-canvas/public/icons/icon-192x192.png b/demos/react-supabase-pixel-canvas/public/icons/icon-192x192.png new file mode 100644 index 000000000..66a723429 Binary files /dev/null and b/demos/react-supabase-pixel-canvas/public/icons/icon-192x192.png differ diff --git a/demos/react-supabase-pixel-canvas/public/icons/icon-256x256.png b/demos/react-supabase-pixel-canvas/public/icons/icon-256x256.png new file mode 100644 index 000000000..1b8b97bae Binary files /dev/null and b/demos/react-supabase-pixel-canvas/public/icons/icon-256x256.png differ diff --git a/demos/react-supabase-pixel-canvas/public/icons/icon-384x384.png b/demos/react-supabase-pixel-canvas/public/icons/icon-384x384.png new file mode 100644 index 000000000..af8be4dc6 Binary files /dev/null and b/demos/react-supabase-pixel-canvas/public/icons/icon-384x384.png differ diff --git a/demos/react-supabase-pixel-canvas/public/icons/icon-512x512.png b/demos/react-supabase-pixel-canvas/public/icons/icon-512x512.png new file mode 100644 index 000000000..eb291c7e4 Binary files /dev/null and b/demos/react-supabase-pixel-canvas/public/icons/icon-512x512.png differ diff --git a/demos/react-supabase-pixel-canvas/public/icons/icon.png b/demos/react-supabase-pixel-canvas/public/icons/icon.png new file mode 100644 index 000000000..c254b17c6 Binary files /dev/null and b/demos/react-supabase-pixel-canvas/public/icons/icon.png differ diff --git a/demos/react-supabase-pixel-canvas/public/powersync-logo.svg b/demos/react-supabase-pixel-canvas/public/powersync-logo.svg new file mode 100644 index 000000000..05e31b6ed --- /dev/null +++ b/demos/react-supabase-pixel-canvas/public/powersync-logo.svg @@ -0,0 +1 @@ + diff --git a/demos/react-supabase-pixel-canvas/public/vercel.json b/demos/react-supabase-pixel-canvas/public/vercel.json new file mode 100644 index 000000000..00e7eccdc --- /dev/null +++ b/demos/react-supabase-pixel-canvas/public/vercel.json @@ -0,0 +1,3 @@ +{ + "routes": [{ "src": "/[^.]+", "dest": "/", "status": 200 }] +} diff --git a/demos/react-supabase-pixel-canvas/src/app/booth/page.tsx b/demos/react-supabase-pixel-canvas/src/app/booth/page.tsx new file mode 100644 index 000000000..74cd1eca0 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/app/booth/page.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { PixelCanvas } from '@/components/PixelCanvas'; +import { QRCodeCard } from '@/components/QRCodeCard'; +import { StatsTicker } from '@/components/StatsTicker'; +import { AdminControls } from '@/components/AdminControls'; +import { usePixels } from '@/library/powersync/hooks'; + +export const BoothPage: React.FC = () => { + const { data: pixels } = usePixels(); + + return ( +
+
+ +
+ + + +
+ ); +}; + +export default BoothPage; diff --git a/demos/react-supabase-pixel-canvas/src/app/draw/page.tsx b/demos/react-supabase-pixel-canvas/src/app/draw/page.tsx new file mode 100644 index 000000000..2dba97170 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/app/draw/page.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { usePowerSync } from '@powersync/react'; + +import { PixelCanvas } from '@/components/PixelCanvas'; +import { ColorPalette } from '@/components/ColorPalette'; +import { SyncStatusBar } from '@/components/SyncStatusBar'; +import { useSupabase } from '@/components/providers/SystemProvider'; +import { usePixels } from '@/library/powersync/hooks'; +import { placePixel } from '@/library/powersync/pixels'; +import { getLocalUserId } from '@/library/userId'; + +export const DrawPage: React.FC = () => { + const powerSync = usePowerSync(); + const connector = useSupabase(); + const { data: pixels } = usePixels(); + + const [selectedColor, setSelectedColor] = React.useState(4); // red — a visible default + const [highlight, setHighlight] = React.useState<{ x: number; y: number } | null>(null); + const highlightTimer = React.useRef>(); + + const userId = connector?.currentSession?.user.id ?? getLocalUserId(); + + const handleTap = React.useCallback( + (x: number, y: number) => { + void placePixel(powerSync, x, y, selectedColor, userId); + setHighlight({ x, y }); + clearTimeout(highlightTimer.current); + highlightTimer.current = setTimeout(() => setHighlight(null), 800); + }, + [powerSync, selectedColor, userId] + ); + + React.useEffect(() => () => clearTimeout(highlightTimer.current), []); + + return ( +
+ +
+ +
+ +
+ ); +}; + +export default DrawPage; diff --git a/demos/react-supabase-pixel-canvas/src/app/globals.css b/demos/react-supabase-pixel-canvas/src/app/globals.css new file mode 100644 index 000000000..7d019cab5 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/app/globals.css @@ -0,0 +1,262 @@ +:root { + --bg: #0e0e12; + --panel: #1a1a22; + --panel-border: #2c2c38; + --text: #f5f5f7; + --text-dim: #a0a0b0; + --accent: #c44eff; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + height: 100%; + margin: 0; +} + +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; +} + +.app-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-dim); + font-size: 1.1rem; +} + +/* Shared canvas element */ +.pixel-canvas { + display: block; + width: 100%; + height: 100%; + aspect-ratio: 1 / 1; + image-rendering: pixelated; + background: #ffffff; + border-radius: 4px; +} + +.pixel-canvas--interactive { + cursor: crosshair; + touch-action: none; + user-select: none; + -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; +} + +/* ---------- Booth page ---------- */ +.booth-page { + position: relative; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 2vmin; +} + +.booth-page__canvas-wrap { + width: min(96vh, 96vw); + height: min(96vh, 96vw); + box-shadow: 0 0 0 1px var(--panel-border), 0 20px 60px rgba(0, 0, 0, 0.5); + border-radius: 6px; + overflow: hidden; +} + +.stats-ticker { + position: fixed; + bottom: 2vmin; + left: 2vmin; + display: flex; + gap: 1.5rem; + background: rgba(20, 20, 28, 0.82); + border: 1px solid var(--panel-border); + border-radius: 10px; + padding: 0.9rem 1.3rem; + backdrop-filter: blur(8px); +} + +.stats-ticker__item { + display: flex; + flex-direction: column; +} + +.stats-ticker__value { + font-size: 1.8rem; + font-weight: 700; + line-height: 1; +} + +.stats-ticker__label { + font-size: 0.75rem; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-top: 0.3rem; +} + +.qr-card { + position: fixed; + bottom: 2vmin; + right: 2vmin; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + background: #ffffff; + color: #111; + border-radius: 12px; + padding: 1rem; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4); +} + +.qr-card__label { + display: flex; + flex-direction: column; + align-items: center; + font-size: 0.8rem; + color: #333; +} + +.qr-card__label span { + color: #777; + font-size: 0.72rem; +} + +.admin-controls { + position: fixed; + top: 1rem; + right: 1rem; + display: flex; + gap: 0.5rem; +} + +.admin-controls button { + background: var(--panel); + color: var(--text); + border: 1px solid var(--panel-border); + border-radius: 8px; + padding: 0.5rem 0.8rem; + font-size: 0.85rem; + cursor: pointer; +} + +.admin-controls button:hover { + border-color: var(--accent); +} + +/* ---------- Draw page ---------- */ +.draw-page { + display: flex; + flex-direction: column; + height: 100%; + max-width: 560px; + margin: 0 auto; + padding: 0.75rem; + gap: 0.75rem; +} + +.draw-page__canvas-wrap { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.draw-page__canvas-wrap .pixel-canvas { + width: min(100%, calc(100vh - 220px)); + height: auto; + box-shadow: 0 0 0 1px var(--panel-border); +} + +/* Sync status bar */ +.sync-bar { + display: flex; + align-items: center; + gap: 0.6rem; + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: 10px; + padding: 0.6rem 0.85rem; + font-size: 0.9rem; +} + +.sync-bar__dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--text-dim); + flex: none; +} + +.sync-bar__dot--connected { + background: #22c55e; +} + +.sync-bar__dot--disconnected { + background: #f59e0b; +} + +.sync-bar__dot--offline { + background: #6b7280; +} + +.sync-bar__state { + font-weight: 600; +} + +.sync-bar__pending { + color: var(--accent); + font-size: 0.82rem; +} + +.sync-bar__spacer { + flex: 1; +} + +.sync-bar__toggle { + background: var(--accent); + color: #fff; + border: none; + border-radius: 8px; + padding: 0.45rem 0.9rem; + font-weight: 600; + cursor: pointer; +} + +.sync-bar__hint { + color: var(--text-dim); + font-size: 0.78rem; +} + +/* Palette */ +.palette { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 0.5rem; +} + +.palette__swatch { + aspect-ratio: 1 / 1; + border: 2px solid var(--panel-border); + border-radius: 8px; + cursor: pointer; + padding: 0; + transition: transform 0.05s ease-out; +} + +.palette__swatch--selected { + border-color: var(--text); + box-shadow: 0 0 0 2px var(--accent); + transform: scale(1.06); +} diff --git a/demos/react-supabase-pixel-canvas/src/app/index.tsx b/demos/react-supabase-pixel-canvas/src/app/index.tsx new file mode 100644 index 000000000..5d8c83b13 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/app/index.tsx @@ -0,0 +1,16 @@ +import { createRoot } from 'react-dom/client'; +import { RouterProvider } from 'react-router-dom'; + +import { SystemProvider } from '@/components/providers/SystemProvider'; +import { router } from '@/app/router'; + +export function App() { + return ( + + + + ); +} + +const root = createRoot(document.getElementById('app')!); +root.render(); diff --git a/demos/react-supabase-pixel-canvas/src/app/router.tsx b/demos/react-supabase-pixel-canvas/src/app/router.tsx new file mode 100644 index 000000000..c0e9f7486 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/app/router.tsx @@ -0,0 +1,15 @@ +import { createBrowserRouter } from 'react-router-dom'; + +import BoothPage from '@/app/booth/page'; +import DrawPage from '@/app/draw/page'; + +export const router = createBrowserRouter([ + { + path: '/', + element: + }, + { + path: '/draw', + element: + } +]); diff --git a/demos/react-supabase-pixel-canvas/src/components/AdminControls.tsx b/demos/react-supabase-pixel-canvas/src/components/AdminControls.tsx new file mode 100644 index 000000000..b6e78c379 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/components/AdminControls.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { usePowerSync } from '@powersync/react'; + +import { clearCanvas } from '@/library/powersync/pixels'; +import { exportCanvasPng } from '@/library/exportPng'; +import { getLocalUserId } from '@/library/userId'; +import { useCanvasStats } from '@/library/powersync/hooks'; +import type { CanvasPixel } from '@/library/powersync/hooks'; + +export interface AdminControlsProps { + /** Current pixels, used for PNG export. */ + pixels: ReadonlyArray; +} + +/** + * Booth admin actions: clear the canvas and export a PNG snapshot. Hidden by + * default; keyboard shortcuts always work (Shift+C clear, Shift+E export) and + * visible buttons appear when the page is opened with `?admin=1`. + */ +export const AdminControls: React.FC = ({ pixels }) => { + const powerSync = usePowerSync(); + // Subscribe so the export always reflects the latest canvas even if the parent + // passes a stale snapshot. + useCanvasStats(); + + const showButtons = React.useMemo(() => new URLSearchParams(window.location.search).has('admin'), []); + + const handleClear = React.useCallback(async () => { + if (!window.confirm('Clear the entire canvas for everyone?')) { + return; + } + await clearCanvas(powerSync, getLocalUserId()); + }, [powerSync]); + + const handleExport = React.useCallback(() => { + void exportCanvasPng(pixels); + }, [pixels]); + + React.useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) { + return; + } + if (e.shiftKey && (e.key === 'C' || e.key === 'c')) { + void handleClear(); + } else if (e.shiftKey && (e.key === 'E' || e.key === 'e')) { + handleExport(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [handleClear, handleExport]); + + if (!showButtons) { + return null; + } + + return ( +
+ + +
+ ); +}; + +export default AdminControls; diff --git a/demos/react-supabase-pixel-canvas/src/components/ColorPalette.tsx b/demos/react-supabase-pixel-canvas/src/components/ColorPalette.tsx new file mode 100644 index 000000000..a77328db1 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/components/ColorPalette.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { PALETTE } from '@/library/palette'; + +export interface ColorPaletteProps { + selected: number; + onSelect: (index: number) => void; +} + +export const ColorPalette: React.FC = ({ selected, onSelect }) => { + return ( +
+ {PALETTE.map((hex, index) => ( +
+ ); +}; + +export default ColorPalette; diff --git a/demos/react-supabase-pixel-canvas/src/components/PixelCanvas.tsx b/demos/react-supabase-pixel-canvas/src/components/PixelCanvas.tsx new file mode 100644 index 000000000..a1a0adac6 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/components/PixelCanvas.tsx @@ -0,0 +1,116 @@ +import React from 'react'; + +import { buildPixelImageData, GRID_SIZE } from '@/library/palette'; +import type { CanvasPixel } from '@/library/powersync/hooks'; + +export interface PixelCanvasProps { + pixels: ReadonlyArray; + interactive?: boolean; + onPixelTap?: (x: number, y: number) => void; + /** Cell to briefly outline (e.g. the just-placed pixel). */ + highlight?: { x: number; y: number } | null; + className?: string; +} + +/** + * Shared canvas renderer for both the booth and draw pages. Paints the pixel + * data into an offscreen 32x32 buffer, then blits it (nearest-neighbour) to the + * display canvas, which is sized to its CSS box scaled by devicePixelRatio. + */ +export const PixelCanvas: React.FC = ({ + pixels, + interactive = false, + onPixelTap, + highlight = null, + className +}) => { + const canvasRef = React.useRef(null); + const offscreenRef = React.useRef(null); + + // Build (and cache) the 32x32 offscreen buffer whenever the pixels change. + React.useEffect(() => { + if (!offscreenRef.current) { + const off = document.createElement('canvas'); + off.width = GRID_SIZE; + off.height = GRID_SIZE; + offscreenRef.current = off; + } + offscreenRef.current.getContext('2d')!.putImageData(buildPixelImageData(pixels), 0, 0); + draw(); + }, [pixels]); + + const draw = React.useCallback(() => { + const canvas = canvasRef.current; + const off = offscreenRef.current; + if (!canvas || !off) { + return; + } + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + ctx.imageSmoothingEnabled = false; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(off, 0, 0, canvas.width, canvas.height); + + if (highlight) { + const cell = canvas.width / GRID_SIZE; + ctx.lineWidth = Math.max(2, cell * 0.12); + ctx.strokeStyle = 'rgba(0,0,0,0.85)'; + ctx.strokeRect(highlight.x * cell, highlight.y * cell, cell, cell); + ctx.strokeStyle = 'rgba(255,255,255,0.9)'; + ctx.lineWidth = Math.max(1, cell * 0.06); + ctx.strokeRect(highlight.x * cell, highlight.y * cell, cell, cell); + } + }, [highlight]); + + // Redraw when the highlight changes. + React.useEffect(() => { + draw(); + }, [draw]); + + // Keep the backing store sized to the element box * devicePixelRatio. + React.useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + const resize = () => { + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const w = Math.max(1, Math.round(rect.width * dpr)); + const h = Math.max(1, Math.round(rect.height * dpr)); + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; + canvas.height = h; + draw(); + } + }; + resize(); + const observer = new ResizeObserver(resize); + observer.observe(canvas); + return () => observer.disconnect(); + }, [draw]); + + const handlePointer = (e: React.PointerEvent) => { + if (!interactive || !onPixelTap) { + return; + } + const canvas = canvasRef.current!; + const rect = canvas.getBoundingClientRect(); + const x = Math.min(GRID_SIZE - 1, Math.max(0, Math.floor(((e.clientX - rect.left) / rect.width) * GRID_SIZE))); + const y = Math.min(GRID_SIZE - 1, Math.max(0, Math.floor(((e.clientY - rect.top) / rect.height) * GRID_SIZE))); + onPixelTap(x, y); + }; + + return ( + + ); +}; + +export default PixelCanvas; diff --git a/demos/react-supabase-pixel-canvas/src/components/QRCodeCard.tsx b/demos/react-supabase-pixel-canvas/src/components/QRCodeCard.tsx new file mode 100644 index 000000000..3ad871517 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/components/QRCodeCard.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { QRCodeSVG } from 'qrcode.react'; + +/** URL of the draw page, resolved against the current origin. */ +function drawUrl(): string { + if (typeof window === 'undefined') { + return '/draw'; + } + return new URL('/draw', window.location.origin).href; +} + +export const QRCodeCard: React.FC = () => { + const url = drawUrl(); + return ( +
+ +
+ Scan to draw + {url.replace(/^https?:\/\//, '')} +
+
+ ); +}; + +export default QRCodeCard; diff --git a/demos/react-supabase-pixel-canvas/src/components/StatsTicker.tsx b/demos/react-supabase-pixel-canvas/src/components/StatsTicker.tsx new file mode 100644 index 000000000..fa3a3d129 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/components/StatsTicker.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { useCanvasStats } from '@/library/powersync/hooks'; + +export const StatsTicker: React.FC = () => { + const { data } = useCanvasStats(); + const stats = data[0] ?? { placed: 0, artists: 0 }; + + return ( +
+
+ {stats.placed.toLocaleString()} + pixels placed +
+
+ {stats.artists.toLocaleString()} + artists +
+
+ ); +}; + +export default StatsTicker; diff --git a/demos/react-supabase-pixel-canvas/src/components/SyncStatusBar.tsx b/demos/react-supabase-pixel-canvas/src/components/SyncStatusBar.tsx new file mode 100644 index 000000000..b5c5e2d1d --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/components/SyncStatusBar.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { usePowerSync, useStatus } from '@powersync/react'; +import { SyncStreamConnectionMethod } from '@powersync/web'; + +import { useSupabase } from '@/components/providers/SystemProvider'; +import { usePendingUploadCount } from '@/library/powersync/hooks'; +import { selectConnectionMethod } from '@/library/powersync/vfs'; + +export const SyncStatusBar: React.FC = () => { + const powerSync = usePowerSync(); + const connector = useSupabase(); + const status = useStatus(); + const { data: pending } = usePendingUploadCount(); + const pendingCount = pending[0]?.count ?? 0; + + const backendConfigured = !!connector; + + const toggleConnection = async () => { + if (!connector) { + return; + } + if (status.connected) { + await powerSync.disconnect(); + } else { + await powerSync.connect(connector, { connectionMethod: selectConnectionMethod() }); + } + }; + + let stateLabel: string; + let stateClass: string; + if (!backendConfigured) { + stateLabel = 'Offline demo'; + stateClass = 'offline'; + } else if (status.connected) { + stateLabel = status.dataFlowStatus.uploading ? 'Syncing…' : 'Connected'; + stateClass = 'connected'; + } else { + stateLabel = 'Disconnected'; + stateClass = 'disconnected'; + } + + return ( +
+ + {stateLabel} + + {pendingCount > 0 && ( + + {pendingCount} pixel{pendingCount === 1 ? '' : 's'} queued + + )} + {backendConfigured ? ( + + ) : ( + + no backend + + )} +
+ ); +}; + +export default SyncStatusBar; diff --git a/demos/react-supabase-pixel-canvas/src/components/providers/SystemProvider.tsx b/demos/react-supabase-pixel-canvas/src/components/providers/SystemProvider.tsx new file mode 100644 index 000000000..bc49c406a --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/components/providers/SystemProvider.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { PowerSyncContext } from '@powersync/react'; +import { SyncStreamConnectionMethod } from '@powersync/web'; + +import { db } from '@/library/powersync/db'; +import { isBackendConfigured } from '@/library/powersync/connection'; +import { seedPixelsIfNeeded } from '@/library/powersync/seed'; +import { SupabaseConnector } from '@/library/powersync/SupabaseConnector'; +import { selectConnectionMethod } from '@/library/powersync/vfs'; + +const SupabaseContext = React.createContext(null); +export const useSupabase = () => React.useContext(SupabaseContext); + +export { db }; + +export const SystemProvider = ({ children }: { children: React.ReactNode }) => { + const [powerSync] = React.useState(db); + const [connector] = React.useState(() => (isBackendConfigured() ? new SupabaseConnector() : null)); + const [ready, setReady] = React.useState(false); + + React.useEffect(() => { + let cleanup: (() => void) | undefined; + + (async () => { + // For console testing/debugging. + (window as any)._powersync = powerSync; + + await powerSync.init(); + + if (connector) { + const l = connector.registerListener({ + sessionStarted: () => { + powerSync.connect(connector, { + connectionMethod: selectConnectionMethod() + }); + } + }); + cleanup = () => l?.(); + await connector.init(); + } else { + // Standalone mode: seed a blank canvas locally so the app is usable + // without any backend. + await seedPixelsIfNeeded(powerSync); + } + + setReady(true); + })(); + + return () => cleanup?.(); + }, [powerSync, connector]); + + if (!ready) { + return
Loading canvas…
; + } + + return ( + + {children} + + ); +}; + +export default SystemProvider; diff --git a/demos/react-supabase-pixel-canvas/src/index.html b/demos/react-supabase-pixel-canvas/src/index.html new file mode 100644 index 000000000..79e400da6 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/index.html @@ -0,0 +1,15 @@ + + + + + + + + PowerSync Pixel Canvas + + + + +
+ + diff --git a/demos/react-supabase-pixel-canvas/src/library/exportPng.ts b/demos/react-supabase-pixel-canvas/src/library/exportPng.ts new file mode 100644 index 000000000..884b5b152 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/library/exportPng.ts @@ -0,0 +1,36 @@ +import { buildPixelImageData, GRID_SIZE } from './palette'; +import type { CanvasPixel } from './powersync/hooks'; + +/** + * Render the canvas to a PNG at a fixed high resolution and trigger a download. + * Rendered fresh from the pixel data (not lifted off the on-screen canvas) so + * the output is independent of screen size, DPR, and the selection highlight. + */ +export async function exportCanvasPng(pixels: ReadonlyArray, scale = 32): Promise { + const size = GRID_SIZE * scale; + + // Paint the 32x32 image, then scale it up with smoothing off for crisp pixels. + const small = document.createElement('canvas'); + small.width = GRID_SIZE; + small.height = GRID_SIZE; + small.getContext('2d')!.putImageData(buildPixelImageData(pixels), 0, 0); + + const out = document.createElement('canvas'); + out.width = size; + out.height = size; + const ctx = out.getContext('2d')!; + ctx.imageSmoothingEnabled = false; + ctx.drawImage(small, 0, 0, size, size); + + const blob = await new Promise((resolve) => out.toBlob(resolve, 'image/png')); + if (!blob) { + return; + } + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `pixel-canvas-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/demos/react-supabase-pixel-canvas/src/library/palette.ts b/demos/react-supabase-pixel-canvas/src/library/palette.ts new file mode 100644 index 000000000..bca526e49 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/library/palette.ts @@ -0,0 +1,63 @@ +import type { PixelRecord } from './powersync/AppSchema'; + +/** Side length of the (square) canvas in cells. */ +export const GRID_SIZE = 32; + +export const PIXEL_COUNT = GRID_SIZE * GRID_SIZE; + +/** + * Colours are stored on each pixel as an index into this palette rather than a + * hex string, so the booth controls the available colours centrally. This is + * the classic r/place 2017 set trimmed to 12. + */ +export const PALETTE = [ + '#FFFFFF', // 0 white + '#E4E4E4', // 1 light grey + '#888888', // 2 grey + '#222222', // 3 black + '#E50000', // 4 red + '#E59500', // 5 orange + '#A06A42', // 6 brown + '#E5D900', // 7 yellow + '#02BE01', // 8 green + '#00D3DD', // 9 cyan + '#0083C7', // 10 blue + '#820080' // 11 purple +] as const; + +/** Default / blank pixel colour (white). */ +export const WHITE_INDEX = 0; + +export const clampColorIndex = (index: number): number => + Math.max(0, Math.min(PALETTE.length - 1, Math.round(index))); + +/** Parse the hex entries into [r, g, b] triples once, for fast ImageData writes. */ +const RGB = PALETTE.map((hex) => { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff] as const; +}); + +/** + * Build a GRID_SIZE x GRID_SIZE ImageData from pixel rows. Any cell without a + * row (or with an out-of-range colour) falls back to white. Shared by the + * on-screen renderer and the PNG exporter so they always agree. + */ +export function buildPixelImageData(pixels: ReadonlyArray>): ImageData { + const data = new Uint8ClampedArray(PIXEL_COUNT * 4); + // Default every cell to opaque white. + data.fill(255); + + for (const p of pixels) { + if (p.x == null || p.y == null || p.x < 0 || p.x >= GRID_SIZE || p.y < 0 || p.y >= GRID_SIZE) { + continue; + } + const [r, g, b] = RGB[clampColorIndex(p.color ?? WHITE_INDEX)]; + const offset = (p.y * GRID_SIZE + p.x) * 4; + data[offset] = r; + data[offset + 1] = g; + data[offset + 2] = b; + data[offset + 3] = 255; + } + + return new ImageData(data, GRID_SIZE, GRID_SIZE); +} diff --git a/demos/react-supabase-pixel-canvas/src/library/powersync/AppSchema.ts b/demos/react-supabase-pixel-canvas/src/library/powersync/AppSchema.ts new file mode 100644 index 000000000..21db353f1 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/library/powersync/AppSchema.ts @@ -0,0 +1,23 @@ +import { column, Schema, Table } from '@powersync/web'; + +export const PIXELS_TABLE = 'pixels'; + +/** + * One row per canvas cell. The row id is the deterministic string `${x}:${y}`, + * so two clients editing the same cell produce PATCH operations against the same + * id — resolved last-write-wins — rather than duplicate rows. + */ +const pixels = new Table({ + x: column.integer, + y: column.integer, + color: column.integer, // palette index (see library/palette.ts) + updated_by: column.text, + updated_at: column.text +}); + +export const AppSchema = new Schema({ + pixels +}); + +export type Database = (typeof AppSchema)['types']; +export type PixelRecord = Database['pixels']; diff --git a/demos/react-supabase-pixel-canvas/src/library/powersync/SupabaseConnector.ts b/demos/react-supabase-pixel-canvas/src/library/powersync/SupabaseConnector.ts new file mode 100644 index 000000000..41dce9886 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/library/powersync/SupabaseConnector.ts @@ -0,0 +1,144 @@ +import { + AbstractPowerSyncDatabase, + BaseObserver, + CrudEntry, + PowerSyncBackendConnector, + UpdateType, + type PowerSyncCredentials +} from '@powersync/web'; + +import { Session, SupabaseClient, createClient } from '@supabase/supabase-js'; + +import { getSupabaseConfig, SupabaseConfig } from './connection'; + +/// Postgres Response codes that we cannot recover from by retrying. +const FATAL_RESPONSE_CODES = [ + // Class 22 — Data Exception (e.g. data type mismatch). + new RegExp('^22...$'), + // Class 23 — Integrity Constraint Violation (NOT NULL, FK, UNIQUE). + new RegExp('^23...$'), + // INSUFFICIENT PRIVILEGE - typically a row-level security violation. + new RegExp('^42501$') +]; + +export type SupabaseConnectorListener = { + initialized: () => void; + sessionStarted: (session: Session) => void; +}; + +export class SupabaseConnector extends BaseObserver implements PowerSyncBackendConnector { + readonly client: SupabaseClient; + readonly config: SupabaseConfig; + + ready: boolean; + + currentSession: Session | null; + + constructor() { + super(); + this.config = getSupabaseConfig(); + + this.client = createClient(this.config.supabaseUrl, this.config.supabaseAnonKey, { + auth: { + persistSession: true + } + }); + this.currentSession = null; + this.ready = false; + } + + async init() { + if (this.ready) { + return; + } + + let sessionResponse = await this.client.auth.getSession(); + if (!sessionResponse.data.session) { + // Anonymous sign-in: every booth visitor becomes a distinct anon user. + // Requires anonymous sign-ins to be enabled on the Supabase project. + const anon = await this.client.auth.signInAnonymously(); + if (anon.error) { + throw anon.error; + } + sessionResponse = await this.client.auth.getSession(); + } + this.updateSession(sessionResponse.data.session); + + this.ready = true; + this.iterateListeners((cb) => cb.initialized?.()); + } + + async fetchCredentials() { + const { + data: { session }, + error + } = await this.client.auth.getSession(); + + if (!session || error) { + throw new Error(`Could not fetch Supabase credentials: ${error}`); + } + + return { + endpoint: this.config.powersyncUrl, + token: session.access_token ?? '' + } satisfies PowerSyncCredentials; + } + + async uploadData(database: AbstractPowerSyncDatabase): Promise { + const transaction = await database.getNextCrudTransaction(); + + if (!transaction) { + return; + } + + let lastOp: CrudEntry | null = null; + try { + // Note: If transactional consistency is important, use database functions + // or edge functions to process the entire transaction in a single call. + for (const op of transaction.crud) { + lastOp = op; + const table = this.client.from(op.table); + let result: any; + switch (op.op) { + case UpdateType.PUT: + const record = { ...op.opData, id: op.id }; + result = await table.upsert(record); + break; + case UpdateType.PATCH: + result = await table.update(op.opData ?? {}).eq('id', op.id); + break; + case UpdateType.DELETE: + result = await table.delete().eq('id', op.id); + break; + } + + if (result.error) { + console.error(result.error); + result.error.message = `Could not update Supabase. Received error: ${result.error.message}`; + throw result.error; + } + } + + await transaction.complete(); + } catch (ex: any) { + console.debug(ex); + if (typeof ex.code == 'string' && FATAL_RESPONSE_CODES.some((regex) => regex.test(ex.code))) { + // Instead of blocking the queue with these errors, discard the (rest of + // the) transaction. These typically indicate an application bug. + console.error('Data upload error - discarding:', lastOp, ex); + await transaction.complete(); + } else { + // Error may be retryable - e.g. network error. Throwing retries later. + throw ex; + } + } + } + + updateSession(session: Session | null) { + this.currentSession = session; + if (!session) { + return; + } + this.iterateListeners((cb) => cb.sessionStarted?.(session)); + } +} diff --git a/demos/react-supabase-pixel-canvas/src/library/powersync/connection.ts b/demos/react-supabase-pixel-canvas/src/library/powersync/connection.ts new file mode 100644 index 000000000..0ef171c6c --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/library/powersync/connection.ts @@ -0,0 +1,25 @@ +export type SupabaseConfig = { + supabaseUrl: string; + supabaseAnonKey: string; + powersyncUrl: string; +}; + +/** + * The backend is only wired up when all three env vars are present. Without + * them the app runs fully standalone (locally-seeded canvas, no uploads). + */ +export function isBackendConfigured(): boolean { + return !!( + import.meta.env.VITE_SUPABASE_URL && + import.meta.env.VITE_SUPABASE_ANON_KEY && + import.meta.env.VITE_POWERSYNC_URL + ); +} + +export function getSupabaseConfig(): SupabaseConfig { + return { + supabaseUrl: import.meta.env.VITE_SUPABASE_URL!, + supabaseAnonKey: import.meta.env.VITE_SUPABASE_ANON_KEY!, + powersyncUrl: import.meta.env.VITE_POWERSYNC_URL! + }; +} diff --git a/demos/react-supabase-pixel-canvas/src/library/powersync/db.ts b/demos/react-supabase-pixel-canvas/src/library/powersync/db.ts new file mode 100644 index 000000000..f1bc9471b --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/library/powersync/db.ts @@ -0,0 +1,15 @@ +import { PowerSyncDatabase, WASQLiteOpenFactory } from '@powersync/web'; + +import { AppSchema } from './AppSchema'; +import { selectMultiTabs, selectVFS } from './vfs'; + +export const db = new PowerSyncDatabase({ + schema: AppSchema, + database: new WASQLiteOpenFactory({ + dbFilename: 'pixel-canvas.db', + vfs: selectVFS(), + flags: { + enableMultiTabs: selectMultiTabs() + } + }) +}); diff --git a/demos/react-supabase-pixel-canvas/src/library/powersync/hooks.ts b/demos/react-supabase-pixel-canvas/src/library/powersync/hooks.ts new file mode 100644 index 000000000..8ef6631b3 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/library/powersync/hooks.ts @@ -0,0 +1,44 @@ +import { useQuery } from '@powersync/react'; + +import type { PixelRecord } from './AppSchema'; + +export type CanvasPixel = Pick; + +/** + * Watch every pixel. Differential (`rowComparator`) so an emission only happens + * when a cell's colour actually changes — `updated_at`/`updated_by` churn and + * unrelated crud-queue activity don't trigger repaints. + */ +export function usePixels() { + return useQuery('SELECT id, x, y, color FROM pixels', [], { + rowComparator: { + keyBy: (p) => p.id, + compareBy: (p) => String(p.color) + } + }); +} + +export type CanvasStats = { placed: number; artists: number }; + +/** Booth stats: cells currently coloured by a real user + distinct artists. */ +export function useCanvasStats() { + return useQuery( + `SELECT + COUNT(*) FILTER (WHERE updated_by IS NOT NULL AND updated_by != 'seed' AND updated_by != '') AS placed, + COUNT(DISTINCT CASE WHEN updated_by != 'seed' AND updated_by != '' THEN updated_by END) AS artists + FROM pixels`, + [] + ); +} + +/** + * Number of local writes not yet uploaded. `ps_crud` is an internal PowerSync + * table; we name it explicitly in `tables` so the watch re-runs on every queue + * change regardless of EXPLAIN-based table resolution. + */ +export function usePendingUploadCount() { + return useQuery<{ count: number }>('SELECT COUNT(*) AS count FROM ps_crud', [], { + tables: ['ps_crud'], + throttleMs: 300 + }); +} diff --git a/demos/react-supabase-pixel-canvas/src/library/powersync/pixels.ts b/demos/react-supabase-pixel-canvas/src/library/powersync/pixels.ts new file mode 100644 index 000000000..eaa25f878 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/library/powersync/pixels.ts @@ -0,0 +1,33 @@ +import { AbstractPowerSyncDatabase } from '@powersync/web'; + +import { WHITE_INDEX } from '../palette'; + +/** Deterministic row id for a cell. */ +export const pixelId = (x: number, y: number): string => `${x}:${y}`; + +/** + * Place (or recolour) a single pixel. This is an UPDATE against a pre-seeded + * row, so it produces a PATCH crud op resolved last-write-wins on the server. + */ +export async function placePixel( + db: AbstractPowerSyncDatabase, + x: number, + y: number, + color: number, + userId: string +): Promise { + await db.execute(`UPDATE pixels SET color = ?, updated_by = ?, updated_at = datetime('now') WHERE id = ?`, [ + color, + userId, + pixelId(x, y) + ]); +} + +/** + * Admin reset: blank every cell. Applies instantly locally; when connected this + * queues ~1024 PATCH uploads that propagate the clear to all clients. For a fast + * server-side reset, run the equivalent UPDATE in the Supabase SQL editor. + */ +export async function clearCanvas(db: AbstractPowerSyncDatabase, userId: string): Promise { + await db.execute(`UPDATE pixels SET color = ?, updated_by = ?, updated_at = datetime('now')`, [WHITE_INDEX, userId]); +} diff --git a/demos/react-supabase-pixel-canvas/src/library/powersync/seed.ts b/demos/react-supabase-pixel-canvas/src/library/powersync/seed.ts new file mode 100644 index 000000000..6a189f94d --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/library/powersync/seed.ts @@ -0,0 +1,44 @@ +import { AbstractPowerSyncDatabase } from '@powersync/web'; + +import { GRID_SIZE, WHITE_INDEX } from '../palette'; +import { pixelId } from './pixels'; + +const SEED_AUTHOR = 'seed'; +const CHUNK_ROWS = 128; // 128 rows * 6 params = 768 bound params per statement. + +/** + * Seed a blank canvas locally so the app works with no backend configured. + * + * Only runs in standalone mode. Writes to the synced `pixels` view queue crud + * (PUT) entries; we delete them in the same transaction so that, if a backend + * is configured later against this same local database, the seed can never + * replay upstream and overwrite the live server canvas. This DELETE FROM ps_crud + * is safe *only* because we are provably standalone (no connector exists, so + * nothing else is ever queued) — never do this when connected. + */ +export async function seedPixelsIfNeeded(db: AbstractPowerSyncDatabase): Promise { + await db.writeTransaction(async (tx) => { + const existing = await tx.get<{ count: number }>(`SELECT COUNT(*) AS count FROM pixels`); + if (existing.count > 0) { + return; + } + + for (let start = 0; start < GRID_SIZE * GRID_SIZE; start += CHUNK_ROWS) { + const values: string[] = []; + const params: (string | number)[] = []; + for (let i = start; i < Math.min(start + CHUNK_ROWS, GRID_SIZE * GRID_SIZE); i++) { + const x = i % GRID_SIZE; + const y = Math.floor(i / GRID_SIZE); + values.push('(?, ?, ?, ?, ?, ?)'); + params.push(pixelId(x, y), x, y, WHITE_INDEX, SEED_AUTHOR, ''); + } + await tx.execute( + `INSERT INTO pixels (id, x, y, color, updated_by, updated_at) VALUES ${values.join(', ')}`, + params + ); + } + + // Discard the queued PUT entries generated by the seed inserts above. + await tx.execute(`DELETE FROM ps_crud`); + }); +} diff --git a/demos/react-supabase-pixel-canvas/src/library/powersync/vfs.ts b/demos/react-supabase-pixel-canvas/src/library/powersync/vfs.ts new file mode 100644 index 000000000..df03c5b90 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/library/powersync/vfs.ts @@ -0,0 +1,39 @@ +import { SyncStreamConnectionMethod, WASQLiteVFS } from '@powersync/web'; + +/** + * WebKit (Safari on macOS, and every browser on iOS/iPadOS since they are all + * WebKit) has historically been the flakiest target for OPFS. We use an + * in-memory database there instead. Everywhere else we persist to OPFS. + */ +export function isWebKit(): boolean { + if (typeof navigator === 'undefined') { + return false; + } + const ua = navigator.userAgent; + + // iPadOS 13+ reports as "MacIntel" desktop Safari; the touch-point count + // distinguishes an actual iPad from a Mac. + const isIOS = /iP(hone|ad|od)/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + if (isIOS) { + // Every iOS browser (Chrome/Firefox/in-app WebViews) is WebKit under the hood. + return true; + } + + // Desktop Safari: has the Safari token but none of the Chromium-family tokens. + return /Safari\//.test(ua) && !/Chrom(e|ium)|CriOS|FxiOS|Edg|OPR|Android/.test(ua); +} + +export function selectVFS(): WASQLiteVFS { + if (isWebKit()) { + return WASQLiteVFS.IDBBatchAtomicVFS; + } + return WASQLiteVFS.OPFSCoopSyncVFS; +} + +export function selectMultiTabs(): boolean { + return isWebKit() ? false : true; +} + +export function selectConnectionMethod(): SyncStreamConnectionMethod { + return SyncStreamConnectionMethod.HTTP; +} diff --git a/demos/react-supabase-pixel-canvas/src/library/powersync/vite-env.d.ts b/demos/react-supabase-pixel-canvas/src/library/powersync/vite-env.d.ts new file mode 100644 index 000000000..7b357472f --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/library/powersync/vite-env.d.ts @@ -0,0 +1,13 @@ +/// + +interface ImportMetaEnv { + readonly VITE_SUPABASE_URL?: string; + readonly VITE_SUPABASE_ANON_KEY?: string; + readonly VITE_POWERSYNC_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} + +declare const APP_VERSION: string; diff --git a/demos/react-supabase-pixel-canvas/src/library/userId.ts b/demos/react-supabase-pixel-canvas/src/library/userId.ts new file mode 100644 index 000000000..eed0194ca --- /dev/null +++ b/demos/react-supabase-pixel-canvas/src/library/userId.ts @@ -0,0 +1,15 @@ +const STORAGE_KEY = 'pixel-canvas-user-id'; + +/** + * Stable per-browser id used to attribute pixels when there is no Supabase + * session (standalone mode). When a backend is configured the anonymous auth + * user id is used instead. Persisted so a returning visitor keeps their identity. + */ +export function getLocalUserId(): string { + let id = localStorage.getItem(STORAGE_KEY); + if (!id) { + id = `local-${crypto.randomUUID()}`; + localStorage.setItem(STORAGE_KEY, id); + } + return id; +} diff --git a/demos/react-supabase-pixel-canvas/sync-config.yaml b/demos/react-supabase-pixel-canvas/sync-config.yaml new file mode 100644 index 000000000..3d4df9249 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/sync-config.yaml @@ -0,0 +1,14 @@ +# PowerSync sync rules for the pixel canvas. +# Paste into the PowerSync Cloud dashboard's sync-streams editor and deploy. +# +# The canvas is a single shared surface that every client sees in full, so there +# is one unfiltered, auto-subscribed stream over all pixels (no auth filter, no +# per-user parameter). +config: + edition: 3 + +streams: + canvas: + auto_subscribe: true + queries: + - SELECT * FROM pixels diff --git a/demos/react-supabase-pixel-canvas/tsconfig.json b/demos/react-supabase-pixel-canvas/tsconfig.json new file mode 100644 index 000000000..cdf3e31e7 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es6", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "exclude": ["node_modules"] +} diff --git a/demos/react-supabase-pixel-canvas/vite.config.mts b/demos/react-supabase-pixel-canvas/vite.config.mts new file mode 100644 index 000000000..f2afabaa6 --- /dev/null +++ b/demos/react-supabase-pixel-canvas/vite.config.mts @@ -0,0 +1,72 @@ +import { fileURLToPath, URL } from 'url'; + +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; +import { VitePWA } from 'vite-plugin-pwa'; + +// https://vitejs.dev/config/ +export default defineConfig({ + root: 'src', + build: { + outDir: '../dist', + rollupOptions: { + input: 'src/index.html' + }, + emptyOutDir: true + }, + resolve: { + alias: [{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) }] + }, + define: { + APP_VERSION: JSON.stringify(process.env.npm_package_version) + }, + publicDir: '../public', + envDir: '..', // Use this dir for env vars, not 'src'. + optimizeDeps: { + // Don't optimize these packages as they contain web workers and WASM files. + // https://github.com/vitejs/vite/issues/11672#issuecomment-1415820673 + exclude: ['@powersync/web'] + }, + plugins: [ + react(), + VitePWA({ + registerType: 'autoUpdate', + includeAssets: ['powersync-logo.svg', 'supabase-logo.png', 'favicon.ico'], + showMaximumFileSizeToCacheInBytesWarning: true, + manifest: { + theme_color: '#c44eff', + background_color: '#c44eff', + display: 'standalone', + scope: '/', + start_url: '/', + name: 'PowerSync React Demo', + short_name: 'PowerSync React', + icons: [ + { + src: '/icons/icon-192x192.png', + sizes: '192x192', + type: 'image/png' + }, + { + src: '/icons/icon-256x256.png', + sizes: '256x256', + type: 'image/png' + }, + { + src: '/icons/icon-384x384.png', + sizes: '384x384', + type: 'image/png' + }, + { + src: '/icons/icon-512x512.png', + sizes: '512x512', + type: 'image/png' + } + ] + } + }) + ], + worker: { + format: 'es' + } +});