Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions demos/react-supabase-pixel-canvas/.env.local.template
Original file line number Diff line number Diff line change
@@ -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=
103 changes: 103 additions & 0 deletions demos/react-supabase-pixel-canvas/README.md
Original file line number Diff line number Diff line change
@@ -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://<project>.supabase.co
VITE_SUPABASE_ANON_KEY=<supabase anon key>
VITE_POWERSYNC_URL=https://<instance>.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.
45 changes: 45 additions & 0 deletions demos/react-supabase-pixel-canvas/database.sql
Original file line number Diff line number Diff line change
@@ -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;
30 changes: 30 additions & 0 deletions demos/react-supabase-pixel-canvas/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
6 changes: 6 additions & 0 deletions demos/react-supabase-pixel-canvas/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
packages:
- .
allowBuilds:
'@journeyapps/wa-sqlite': true
'@swc/core': true
esbuild: true
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions demos/react-supabase-pixel-canvas/public/vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"routes": [{ "src": "/[^.]+", "dest": "/", "status": 200 }]
}
24 changes: 24 additions & 0 deletions demos/react-supabase-pixel-canvas/src/app/booth/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="booth-page">
<div className="booth-page__canvas-wrap">
<PixelCanvas pixels={pixels} />
</div>
<StatsTicker />
<QRCodeCard />
<AdminControls pixels={pixels} />
</div>
);
};

export default BoothPage;
46 changes: 46 additions & 0 deletions demos/react-supabase-pixel-canvas/src/app/draw/page.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof setTimeout>>();

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 (
<div className="draw-page">
<SyncStatusBar />
<div className="draw-page__canvas-wrap">
<PixelCanvas pixels={pixels} interactive onPixelTap={handleTap} highlight={highlight} />
</div>
<ColorPalette selected={selectedColor} onSelect={setSelectedColor} />
</div>
);
};

export default DrawPage;
Loading
Loading