Skip to content

ChryssaAliferi/stickerboard

Repository files navigation

Stickerboard

A shared sticker board for ideas, retrospectives, and brainstorming sessions. Open a link, drop a sticker, react to others, drag it where it belongs — everyone in the room sees it live.

License: MIT Stack

🎯 What this is: Think Miro / FigJam / Mural, but tiny, focused on sticky notes only, and free to self-host. Built to demonstrate that real-time collaboration doesn't require a custom backend.

🧑‍💻 Who it's for: Teams running async retros, workshops, or quick group brainstorms. Anyone who wants a "drop your idea here, see what others think" surface that works the moment you share a link.

Stickerboard board view: six pastel category columns (Product Ideas, Improvements, Customer Pain Points, Technical Debt, Questions, Risks) filled with stickers showing titles, descriptions, author chips, and reaction counts; presence avatars and an Add-sticker button in the top bar

Live demo: https://stickerboard-nu.vercel.app/


Table of contents


Features

  • 🔗 Open boards — share a link, anyone with it can join. No signup, no accounts to manage.
  • 🟨 Stickers in 6 default categories — Product Ideas, Improvements, Customer Pain Points, Technical Debt, Questions, Risks. Admins can rename, recolor, reorder, or add new ones.
  • Real-time collaboration — stickers, reactions, presence avatars, and the lock state sync across every browser within a few hundred milliseconds. No refresh needed.
  • 🎯 Drag & drop — reorder stickers within a column, move them between columns. Touch + keyboard supported.
  • 👍 ❤️ 🚀 ❓ Reactions — four types per sticker, toggle on/off, counts update live.
  • 🛡 Roles — board creator is admin; admins can moderate any sticker, manage categories and members, lock the board.
  • 🔒 Lockable — admin freezes the board after a session. Visitors keep read access; everyone sees a "Locked" banner instantly.
  • 📥 CSV export — one click, opens cleanly in Excel/Sheets, includes reaction counts and Unicode (Greek, CJK, Arabic, emoji all round-trip).
  • 📱 Responsive — desktop grid, mobile pill-tab layout.
  • 🔔 Toasts + error boundary — failures surface to the user; a top-level boundary catches render crashes.

Tech stack and why

Layer Choice Why this one
Frontend Vite + React 19 + TypeScript Fast dev loop, mature ecosystem, types catch issues before users do.
Styling Tailwind v4 with CSS @theme tokens Tokens live next to the styles that use them; no separate config file. Tokens match the Pencil mocks exactly.
Routing react-router-dom Tiny, no router-as-framework lock-in. Three routes total (/, /b/:slug, /b/:slug/admin).
Drag & drop @dnd-kit Accessible (keyboard support, ARIA), good touch handling, doesn't fight React 18+ concurrent rendering.
Backend / DB Supabase (Postgres + Realtime + Auth + RLS) A managed Postgres with realtime change feeds and row-level security baked in. Removes 90% of the "build a backend" work.
Auth Supabase Anonymous Sign-Ins An open-link board needs identity (so RLS can say "you can edit your own stickers") but shouldn't force signup. Anonymous sessions give every visitor a stable user ID without an email.
Export papaparse Tiny, handles quoting / Unicode / BOM correctly. Runs entirely in the browser; no server endpoint.
Testing Vitest + Testing Library + jsdom Vitest reuses the Vite pipeline, so tests run in ~1 s. Testing Library is the conventional React component-test stack; jsdom gives us a DOM without booting a browser.
Hosting Vercel for the frontend, Supabase for the backend Both have generous free tiers; both auto-deploy from a Git push.

Design source of truth: stickerboard.pen (Pencil). Every screen and component in the React app traces back to a frame in this file — colors, radii, shadow values are pulled directly from the Pencil tokens.


Architecture at a glance

┌─────────────────────┐                      ┌──────────────────────────┐
│  Browser A (admin)  │                      │  Browser B (visitor)     │
│  ─────────────────  │                      │  ──────────────────────  │
│  React + Vite       │                      │  React + Vite            │
│  @dnd-kit, Tailwind │                      │  @dnd-kit, Tailwind      │
└──────────┬──────────┘                      └────────────┬─────────────┘
           │                                              │
           │ HTTPS (PostgREST)                            │ HTTPS (PostgREST)
           │ WebSocket (Realtime)                         │ WebSocket (Realtime)
           │                                              │
           └────────────────────┬─────────────────────────┘
                                │
                         ┌──────▼─────────────────────────┐
                         │  Supabase (managed)            │
                         │  ┌──────────────────────────┐  │
                         │  │ Postgres (RLS enforced)  │  │
                         │  │  boards, categories,     │  │
                         │  │  stickers, reactions,    │  │
                         │  │  memberships             │  │
                         │  └──────────────────────────┘  │
                         │  ┌──────────────────────────┐  │
                         │  │ Realtime broadcaster     │  │
                         │  │ tails Postgres WAL,      │  │
                         │  │ pushes change events     │  │
                         │  └──────────────────────────┘  │
                         │  ┌──────────────────────────┐  │
                         │  │ Auth (anonymous + JWT)   │  │
                         │  └──────────────────────────┘  │
                         └────────────────────────────────┘

The key idea: when Browser A inserts a sticker, Postgres writes the row, Supabase Realtime sees the change in the WAL, broadcasts an INSERT event over the WebSocket, and Browser B's React state updates — usually under 300 ms. No server for you to maintain.

What it costs: Supabase and Vercel free tiers cover everyday use comfortably (sub-MAU workshops, small teams, demos). Expect to upgrade only if you sustain hundreds of daily concurrent connections.


Quick start (local dev)

Prerequisites

Tool Why Install
Node.js ≥ 20 Runs Vite https://nodejs.org/ or brew install node
Supabase project Backend Free at https://supabase.com/dashboard/projects
Supabase CLI Pushes migrations brew install supabase/tap/supabase

1 · Clone and install

git clone https://github.com/ChryssaAliferi/stickerboard.git
cd stickerboard
npm install

2 · Configure your Supabase project

In https://supabase.com/dashboard:

  1. Authentication → Providers → Anonymous Sign-Ins: toggle on. The app uses anonymous sessions for the open-link join flow; if this is off the app can't sign anyone in.
  2. Settings → API:
    • Copy the project URL (looks like https://xxxxxxxxxxxxxxxxxxxx.supabase.co).
    • Copy the publishable key (starts with sb_publishable_). This is the new public client key — safe to expose in the bundle. Don't copy the secret key.

3 · Local env vars

cp .env.example .env.local

Open .env.local and fill in:

VITE_SUPABASE_URL=https://YOUR-PROJECT.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxxxxxxxxxxx

.env.local is gitignored. Real secrets must never be committed — see Open-source hygiene.

4 · Apply database migrations

The whole schema lives in supabase/migrations/ as ordered SQL files. Push them to your project:

supabase login                                     # opens a browser, do this once
supabase link --project-ref YOUR-PROJECT-REF       # connects this folder
supabase db push                                   # runs every migration in order

Where to find your project ref: it's the 20-character subdomain in your Supabase project URL — https://<PROJECT-REF>.supabase.co. You can also see it in the dashboard at Settings → General → Reference ID.

After this, you'll have:

  • 5 tables: boards, categories, stickers, reactions, memberships
  • RLS policies covering open-link reads, author-write, admin-all, and the lock model
  • Triggers that auto-create the admin membership for a board's creator, auto-place new stickers at the end of their column, denormalize board_id onto reactions, and block self-promotion
  • The data tables published on the realtime channel with REPLICA IDENTITY FULL so DELETE events make it through filters

5 · Run

npm run dev

Walkthrough

A typical end-to-end use:

Creator's perspective

  1. Lands on /. Names the board ("Q2 Retro") and themself ("Maya"), clicks Create board.
  2. App creates the board row (created_by = anon UID), a Postgres trigger auto-creates the admin membership.
  3. App seeds the 6 default categories with their pastel colors.
  4. App redirects to /b/q2-retro-7r2k4. Maya sees an empty board and an "Admin panel" link in the top bar.
  5. She copies the URL and shares it in Slack.

Visitor's perspective

  1. Diego clicks the link. App signs him in anonymously, prompts for a display name, registers him as a user.
  2. He sees Maya's avatar in the presence stack, plus a "Live · 2 online" indicator. He drops a few stickers; they appear instantly on Maya's screen.
  3. He reacts 👍 to one of Maya's stickers; the count flips from 0 → 1 on her screen.

Wrapping up

  1. Session ends. Maya goes to Admin panel → Board settings, flips Lock board.
  2. Diego's screen instantly shows the dark "Locked" banner; his Add-sticker / drag / Edit buttons disappear.
  3. Maya goes to Admin panel → Export, clicks Download CSV, opens the file in Sheets and shares it with the team.

Database schema

All tables live in the public schema with RLS enabled.

boards                      categories                stickers
─────────────               ─────────────             ─────────────
id          uuid PK         id          uuid PK       id           uuid PK
slug        text UNIQUE     board_id    uuid FK ──┐   board_id     uuid FK ──┐
name        text            name        text      │   category_id  uuid FK ──┤
is_locked   bool            color       text      │   title        text      │
created_by  uuid (auth)     position    int       │   description  text      │
created_at  timestamptz     created_at  ts        │   color        text      │
                                                  │   author_id    uuid      │
                                                  │   author_name  text      │
                                                  │   position     int       │
                                                  │   created_at   ts        │
                                                  │   updated_at   ts        │
                                                  ▼                          │
memberships                              reactions                           │
─────────────                            ─────────────                       │
board_id     uuid FK ───┐                id          uuid PK                 │
user_id      uuid FK    │                board_id    uuid FK (denorm) ───────┘
role         enum       │                sticker_id  uuid FK
display_name text       │                user_id     uuid FK
created_at   ts         │                type        enum (like|love|impact|clarify)
PRIMARY KEY  (board, u) │                created_at  ts
                        │                UNIQUE (sticker, user, type)
                        ▼
                  (admin | user)

Key invariants enforced at the DB level:

  • Author can't fake another author: stickers.author_id = auth.uid() is checked on insert (RLS).
  • One reaction-per-type-per-user: enforced by the unique constraint, which the realtime client treats as a safe toggle.
  • Self-promote is blocked: a trigger refuses any membership role change unless the caller is already an admin.
  • Locked boards reject non-admin writes: helper is_board_unlocked(board_id) is referenced by every sticker/reaction policy.

How key pieces work

Anonymous-link join flow

When a visitor hits /b/:slug:

  1. useAuth hook signs them in anonymously if no session exists. They get a stable Supabase user.id (a UUID).
  2. Board route fetches the board by slug. If not found → 404.
  3. Looks up an existing membership; if none, calls joinBoardAsUser (insert into memberships with role user). The "self-join as user" RLS policy permits this only for the calling user with role user.
  4. If localStorage has no display name, shows the DisplayNameDialog. On submit, localStorage is set and the membership row's display_name is updated.

The board creator skips most of this: a Postgres trigger (handle_new_board) auto-inserts an admin membership when their board row is inserted. The client then patches display_name on that row.

Realtime channels

The useRealtime* hooks subscribe to Postgres change events filtered by board:

  • useRealtimeBoard(boardId) — watches the single board row for the lock toggle.
  • useRealtimeStickers(boardId) — INSERT / UPDATE / DELETE on stickers filtered by board_id.
  • useRealtimeReactions(boardId) — INSERT / DELETE on reactions filtered by the denormalized board_id column (a trigger fills it in from sticker_id).
  • usePresence(boardId, userId, displayName) — Supabase Presence channel, broadcasts { user_id, display_name, joined_at }.

Two non-obvious bits:

  • REPLICA IDENTITY FULL is required on each table or DELETE events would only carry the primary key in the WAL — the realtime filter on board_id would never match and subscribers would never see deletes.
  • Each handler is idempotent so an INSERT race (event arrives before initial SELECT returns) doesn't double-add.

RLS in plain English

Table Read Insert Update Delete
boards anyone only authenticated, must be created_by = auth.uid() only board admins only board admins
categories anyone only board admins only board admins only board admins
stickers anyone author = auth.uid() AND (board unlocked OR caller is admin) own + unlocked, or admin own + unlocked, or admin
reactions anyone own + unlocked, or admin (no update policy by design) own, or admin
memberships anyone self-join as user, OR admin self-rename, OR admin (role-change blocked by trigger) only admins

Drag & drop

@dnd-kit wraps the column grid in a DndContext. Each CategoryColumn is a SortableContext listing its sticker IDs; each SortableSticker is a useSortable item.

On drop:

  1. Compute the new order for the source column (without the moved sticker) and the destination column (with the moved sticker inserted at the drop index).
  2. Optimistically update local state so the UI doesn't flicker.
  3. Persist via parallel UPDATEs — set new position for every affected row, plus category_id on the moved sticker if it crossed columns.
  4. The same updates come back via realtime, harmlessly overwriting our optimistic state with the same values.

If the persist fails (e.g. RLS blocks because the user isn't the author), we surface a toast and trust the next realtime update to reconcile.


Project structure

.
├── README.md                    ← you are here
├── stickerboard.pen             ← Pencil designs (5 screens)
├── LICENSE                      ← MIT
├── vercel.json                  ← SPA rewrite for client routing
├── vitest.config.ts             ← jsdom + Testing Library setup
├── .env.example                 ← env-var contract
│
├── src/
│   ├── App.tsx                  ← router + ToastProvider + ErrorBoundary
│   ├── main.tsx                 ← entry; wraps in BrowserRouter
│   ├── index.css                ← Tailwind v4 @theme tokens
│   ├── vite-env.d.ts            ← typed import.meta.env
│   │
│   ├── components/              ← presentational + small wrappers
│   │   ├── AppShell.tsx
│   │   ├── StickerCard.tsx          (+ .test.tsx)
│   │   ├── SortableSticker.tsx
│   │   ├── CategoryColumn.tsx
│   │   ├── StickerDialog.tsx
│   │   ├── ReactionBar.tsx          (+ .test.tsx)
│   │   ├── PresenceStack.tsx
│   │   ├── MobileCategoryTabs.tsx
│   │   ├── DisplayNameDialog.tsx    (+ .test.tsx)
│   │   ├── Skeleton.tsx
│   │   ├── Toast.tsx
│   │   ├── ToastContext.ts
│   │   ├── ErrorBoundary.tsx
│   │   ├── AdminCategoriesPanel.tsx
│   │   ├── AdminMembersPanel.tsx
│   │   ├── AdminSettingsPanel.tsx
│   │   └── AdminExportPanel.tsx
│   │
│   ├── hooks/                   ← reusable hooks
│   │   ├── useAuth.ts
│   │   ├── useRealtimeBoard.ts
│   │   ├── useRealtimeStickers.ts
│   │   ├── useRealtimeReactions.ts
│   │   ├── usePresence.ts
│   │   ├── useToast.ts
│   │   └── useMediaQuery.ts
│   │
│   ├── lib/                     ← API + helpers, no React
│   │   ├── supabase.ts          ← typed client
│   │   ├── boards.ts            ← create/get/lock/rename
│   │   ├── categories.ts        ← list/create/update/delete/reorder
│   │   ├── stickers.ts          ← list/create/update/delete/move
│   │   ├── reactions.ts         ← list/toggle
│   │   ├── members.ts           ← list/setRole/remove
│   │   ├── export.ts            ← CSV generation via papaparse  (+ .test.ts)
│   │   ├── download.ts          ← Blob → file download
│   │   ├── slug.ts              ← name → URL slug               (+ .test.ts)
│   │   ├── time.ts              ← relative-time formatter       (+ .test.ts)
│   │   └── storage.ts           ← localStorage display-name
│   │
│   ├── routes/
│   │   ├── Landing.tsx          ← /
│   │   ├── Board.tsx            ← /b/:slug
│   │   └── Admin.tsx            ← /b/:slug/admin
│   │
│   └── test/
│       └── setup.ts             ← jest-dom matchers + cleanup() per test
│
├── supabase/
│   ├── config.toml              ← supabase init output
│   └── migrations/              ← ordered SQL — DON'T EDIT, only add new
│       ├── 0001_init.sql                  schema, indexes, enums
│       ├── 0002_rls.sql                   RLS policies + helpers
│       ├── 0003_creator_admin_trigger.sql
│       ├── 0004_realtime.sql              add tables to publication
│       ├── 0005_replica_identity.sql
│       ├── 0006_reactions_board_id.sql
│       ├── 0007_sticker_position_trigger.sql
│       └── 0008_memberships_display_name.sql
│
└── scripts/
    └── db-dump.sh               ← schema + data backup

Scripts

Command What it does
npm run dev Vite dev server with HMR, on port 5173
npm run build TS check + production build to dist/
npm run preview Serve the production build locally (sanity check)
npm run lint ESLint over the whole src/ tree
npm run format Prettier write
npm run format:check Prettier check (CI-friendly)
npm test Vitest in watch mode (re-runs on save)
npm run test:run Vitest single run, exits non-zero on failure (CI-friendly)
./scripts/db-dump.sh Snapshot schema + data of the linked Supabase project to backups/

Tests

Vitest + Testing Library, configured in vitest.config.ts to use a jsdom environment. Tests live next to the source they cover (foo.tsfoo.test.ts).

Currently covered (~42 tests, runs in ~1 s):

  • Pure helpersslug.ts (URL-friendly slugs), time.ts (relative timestamps), and export.ts's buildExportRows + rowsToCsv (CSV row construction, BOM, escaping, Unicode round-trip).
  • ComponentsStickerCard, ReactionBar, DisplayNameDialog. Render output, accessibility queries, callback firing, prop-driven gating (e.g. Edit/Delete only when canModerate, reaction buttons disabled when locked).

Not yet covered:

  • Hooks that talk to Supabase (useAuth, useRealtime*, usePresence) — these would need a Supabase client mock.
  • Integration tests that drive Board.tsx end-to-end against a mocked backend.

Add tests for any new pure logic before opening a PR. Component tests are appreciated but not enforced.


Deploying to Vercel

The repo is set up to deploy on every push to main:

  1. Go to https://vercel.com/new.
  2. Click Import next to the GitHub repo.
  3. Framework preset: Vite (auto-detected).
  4. Root directory: leave at ./.
  5. Build command and output directory: leave defaults (npm run builddist).
  6. Environment Variables — add both, ticked for Production / Preview / Development:
    • VITE_SUPABASE_URL
    • VITE_SUPABASE_PUBLISHABLE_KEY
  7. Click Deploy. The first build takes ~30 seconds.

Vercel hands you a URL like https://stickerboard-<hash>.vercel.app. Visit it; create a board; share the URL with someone on a different network. They should be able to join, drop stickers, and you should see them in real time.

The committed vercel.json rewrites every path to index.html, so deep links like https://your-app.vercel.app/b/q2-retro resolve correctly when typed directly into the address bar (otherwise Vercel's static file server would 404).

Custom domain (optional)

Vercel → project → Settings → Domains → add domain, follow the DNS instructions. Free for any domain you own.

Error tracking (optional)

The app ships with a top-level ErrorBoundary that catches render crashes and shows a Reload screen. For production telemetry, drop in @sentry/react (or an alternative) and initialize it in main.tsx. Not bundled by default so the JS payload stays small.


Open-source hygiene (don't leak secrets)

This project was built to be open from day one. Some rules baked into the repo:

  • .env.local and any .env.*.local, *.pem, *.key, *.p12, secrets/, *.local.json are gitignored.
  • The publishable Supabase key is designed to be public — it ships in the JS bundle and only allows the operations RLS permits anyway. But still, treat all keys as one-way doors: once committed, history is permanent.
  • Server-only secrets (SUPABASE_SECRET_KEY, DATABASE_URL) must never appear with a VITE_ prefix. Vite would inline them into the public bundle.
  • If you're forking and committing somewhere else, the LICENSE requires you keep the copyright notice.

FAQ

Q: Do users need accounts to use a board? No. Anonymous Supabase sessions give every visitor a stable user ID without an email. Authorship and RLS work normally.

Q: Is the publishable key really safe to put in the client? Yes. It has no admin powers; every action it can take is gated by RLS. Compare it to a database "API key for guests" — the only thing it lets you do is what RLS allows.

Q: Can I use a different name / domain / branding? Sure. The MIT license permits it. index.html <title> and src/components/AppShell.tsx are the obvious places to start; the design tokens in src/index.css are the rest.

Q: How do I change my own display name? There's no rename UI yet — clear the browser's localStorage for the site (DevTools → Application → Local Storage → delete the stickerboard.displayName key). On your next visit the join dialog reappears and asks for a new name. (A proper rename action is a small post-v1 addition.)

Q: How do I delete a board? There's no UI for it yet. Run delete from boards where id = '...' against your Supabase database — the foreign keys cascade so categories, stickers, reactions, and memberships go with it.

Q: How do I support languages other than English? Out of the box, the UI is English. The Inter + Geist Mono fonts loaded from Google Fonts cover Greek, Cyrillic, and most Latin extensions automatically. Sticker content already round-trips Unicode (Greek, CJK, Arabic, emoji) through the database and the CSV export. UI text localization (i18n) is post-v1 work.

Q: How is "open to anyone with the link" actually secured? The slug is a generated random suffix. RLS allows reads for anyone but only authors can write to their own stickers and only admins can do anything else. If a slug leaks, the worst case is people add stickers — they can't escalate to admin, can't delete others' content, can't see anything outside that one board.


Troubleshooting

Anonymous sign-in fails with Anonymous sign-ins are disabled Toggle it on at Authentication → Providers → Anonymous Sign-Ins in the Supabase dashboard.

Realtime never delivers DELETE events The table needs REPLICA IDENTITY FULL. Run migration 0005_replica_identity.sql (already applied if you supabase db push'd the whole folder).

Migrations fail with permission errors You're probably not linked to the project. Run supabase link --project-ref YOUR-PROJECT-REF again.

Vercel build fails: "Cannot find module …" Make sure npm install completed locally first, and that package-lock.json is committed. Vercel reads from the lockfile.

/b/something returns a Vercel 404 on the deployed site The SPA rewrite in vercel.json should fix this. If it doesn't, check that vercel.json is at the repo root and was included in the deployed commit.

Locked board still lets a visitor edit on screen RLS will block the actual write, but the UI gate may be stale. Refresh; the is_locked value comes through realtime so the next event from the server will sync it.

Display name on the membership row doesn't match localStorage The membership update happens after the dialog submits and may fail silently if there's a transient network issue. The next time the user submits the dialog (or a future "rename" UI is added), it'll catch up.


Contributing

PRs welcome. Quick rules:

  1. npm run lint && npm run format:check && npm run test:run should be clean.
  2. Database changes go in a new ordered migration file under supabase/migrations/ and ship via supabase db push. Never edit existing migration files — they may have been applied to other forks/installs already.
  3. UI changes should match the tokens in src/index.css (which mirror stickerboard.pen). If a UI change needs a new color or radius, update the .pen file too so the design and code stay in sync.

License

MIT — see LICENSE. Free for personal, commercial, or educational use; just keep the copyright notice.

About

A self hosted, shared sticker board for ideas, retrospectives, and brainstorming sessions. Open a link, drop a sticker, react to others, drag it where it belongs — everyone in the room sees it live.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages