Files
jackboxpartypack-gamepicker/docs/superpowers/plans/2026-08-24-sticker-wall-preview.md

21 KiB
Raw Blame History

Sticker Wall Preview Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Display sticker wall images as previews in the live Picker view and as historical records in the SessionDetail view, fetched through backend proxy endpoints.

Architecture: Two Express proxy routes forward sticker wall requests to the downstream vote-app (keeping the API key server-side). A useStickerWall React hook encapsulates the shared two-step fetch logic (GET cached → POST render on 404). Two new reusable components (StickerWallThumbnail, StickerWallModal) are consumed by both Picker.jsx and SessionDetail.jsx.

Tech Stack: Node.js/Express (backend proxy), React 18 with hooks (frontend), Axios (HTTP), Tailwind CSS (styling)

Global Constraints

  • No TypeScript — all files are .js / .jsx
  • Tailwind CSS for all styling — no CSS modules or styled-components
  • Dark mode via dark: variants on all visual elements
  • Shared axios instance at frontend/src/api/axios.js for all frontend HTTP calls (it sets baseURL: '/api' and injects JWT via interceptor)
  • Backend auth middleware: const { authenticateToken } = require('../middleware/auth') — use on all new routes
  • Modal pattern: fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50, close on Escape + overlay click
  • No inline imports — all imports at top of file
  • Exhaustive switch defaults with never check where applicable
  • Blob URLs must be revoked on unmount and replacement to prevent memory leaks

Task 1: Backend Proxy Endpoints

Files:

  • Create: backend/routes/stickerwalls.js
  • Modify: backend/server.js (lines 22–37, route registration block)

Interfaces:

  • Consumes: authenticateToken from ../middleware/auth

  • Produces:

    • GET /api/stickerwalls/:sessionId — proxies to vote-app GET /stickerwalls/{sessionID}.png, streams PNG response. Returns 200 (image/png) or 404.
    • POST /api/stickerwalls/:sessionId/render — proxies to vote-app POST /api/stickerwalls/{sessionID}/render with X-API-Key header. Streams PNG response. Returns 200 (image/png) or 404. Forwards X-Sticker-Wall-Rendered response header.
    • Reads env vars: VOTE_APP_URL (base URL), VOTE_APP_API_KEY (render auth key)
  • Step 1: Create backend/routes/stickerwalls.js with GET endpoint

const express = require('express');
const { authenticateToken } = require('../middleware/auth');

const router = express.Router();

const VOTE_APP_URL = process.env.VOTE_APP_URL;
const VOTE_APP_API_KEY = process.env.VOTE_APP_API_KEY;

router.get('/:sessionId', authenticateToken, async (req, res) => {
  const { sessionId } = req.params;
  if (!VOTE_APP_URL) {
    return res.status(503).json({ error: 'Vote app not configured' });
  }

  try {
    const upstream = await fetch(`${VOTE_APP_URL}/stickerwalls/${sessionId}.png`);
    if (!upstream.ok) {
      return res.status(upstream.status).end();
    }
    res.set('Content-Type', 'image/png');
    const cacheControl = upstream.headers.get('cache-control');
    if (cacheControl) {
      res.set('Cache-Control', cacheControl);
    }
    const buffer = Buffer.from(await upstream.arrayBuffer());
    res.send(buffer);
  } catch (err) {
    console.error('Sticker wall GET proxy error:', err.message);
    res.status(502).json({ error: 'Failed to fetch sticker wall' });
  }
});

module.exports = router;
  • Step 2: Add POST render endpoint to the same file

Add below the GET route, before module.exports:

router.post('/:sessionId/render', authenticateToken, async (req, res) => {
  const { sessionId } = req.params;
  if (!VOTE_APP_URL || !VOTE_APP_API_KEY) {
    return res.status(503).json({ error: 'Vote app not configured' });
  }

  try {
    const upstream = await fetch(`${VOTE_APP_URL}/api/stickerwalls/${sessionId}/render`, {
      method: 'POST',
      headers: { 'X-API-Key': VOTE_APP_API_KEY },
    });
    if (!upstream.ok) {
      return res.status(upstream.status).end();
    }
    res.set('Content-Type', 'image/png');
    const rendered = upstream.headers.get('x-sticker-wall-rendered');
    if (rendered) {
      res.set('X-Sticker-Wall-Rendered', rendered);
    }
    const buffer = Buffer.from(await upstream.arrayBuffer());
    res.send(buffer);
  } catch (err) {
    console.error('Sticker wall render proxy error:', err.message);
    res.status(502).json({ error: 'Failed to render sticker wall' });
  }
});
  • Step 3: Register the route in backend/server.js

Add to the imports block (after line 29, the webhooksRoutes line):

const stickerwallsRoutes = require('./routes/stickerwalls');

Add to the route registration block (after line 37, the webhooks mount):

app.use('/api/stickerwalls', stickerwallsRoutes);
  • Step 4: Verify the server starts without errors

Run: cd backend && node -e "require('./routes/stickerwalls')" Expected: No errors (clean require)

  • Step 5: Commit
git add backend/routes/stickerwalls.js backend/server.js
git commit -m "feat: add backend proxy endpoints for sticker wall retrieval and rendering"

Task 2: useStickerWall Hook

Files:

  • Create: frontend/src/hooks/useStickerWall.js

Interfaces:

  • Consumes: api from ../api/axios (axios instance with baseURL: '/api' and JWT interceptor)

  • Produces: useStickerWall(sessionId) hook returning { imageUrl, loading, error, refresh }

    • imageUrl — blob URL string or null
    • loading — boolean
    • error — boolean (true when no sticker wall exists)
    • refresh() — async function, calls POST render endpoint, updates imageUrl
  • Step 1: Create frontend/src/hooks/useStickerWall.js

import { useState, useEffect, useRef, useCallback } from 'react';
import api from '../api/axios';

export function useStickerWall(sessionId) {
  const [imageUrl, setImageUrl] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(false);
  const urlRef = useRef(null);

  const revokeUrl = useCallback(() => {
    if (urlRef.current) {
      URL.revokeObjectURL(urlRef.current);
      urlRef.current = null;
    }
  }, []);

  const setBlobUrl = useCallback((blob) => {
    revokeUrl();
    const url = URL.createObjectURL(blob);
    urlRef.current = url;
    setImageUrl(url);
  }, [revokeUrl]);

  const fetchWall = useCallback(async (id) => {
    setLoading(true);
    setError(false);
    try {
      const res = await api.get(`/stickerwalls/${id}`, { responseType: 'blob' });
      setBlobUrl(res.data);
    } catch (err) {
      if (err.response?.status === 404) {
        try {
          const renderRes = await api.post(`/stickerwalls/${id}/render`, null, { responseType: 'blob' });
          setBlobUrl(renderRes.data);
        } catch (renderErr) {
          revokeUrl();
          setImageUrl(null);
          setError(true);
        }
      } else {
        revokeUrl();
        setImageUrl(null);
        setError(true);
      }
    } finally {
      setLoading(false);
    }
  }, [setBlobUrl, revokeUrl]);

  const refresh = useCallback(async () => {
    if (!sessionId) return;
    setLoading(true);
    setError(false);
    try {
      const res = await api.post(`/stickerwalls/${sessionId}/render`, null, { responseType: 'blob' });
      setBlobUrl(res.data);
    } catch {
      setError(true);
    } finally {
      setLoading(false);
    }
  }, [sessionId, setBlobUrl]);

  useEffect(() => {
    if (sessionId) {
      fetchWall(sessionId);
    } else {
      revokeUrl();
      setImageUrl(null);
      setError(false);
    }
    return revokeUrl;
  }, [sessionId, fetchWall, revokeUrl]);

  return { imageUrl, loading, error, refresh };
}
  • Step 2: Verify the file can be imported without syntax errors

Run: cd frontend && node -e "try { require('./src/hooks/useStickerWall.js') } catch(e) { if (e.code === 'ERR_REQUIRE_ESM' || e.message.includes('Cannot use import')) { console.log('ESM syntax OK'); process.exit(0); } throw e; }" Expected: "ESM syntax OK" (the file uses ESM imports so it won't work with require, but it proves no syntax errors prevent parsing)

  • Step 3: Commit
git add frontend/src/hooks/useStickerWall.js
git commit -m "feat: add useStickerWall hook for shared fetch/render logic"

Task 3: StickerWallModal Component

Files:

  • Create: frontend/src/components/StickerWallModal.jsx

Interfaces:

  • Consumes: nothing external (self-contained presentational component)

  • Produces: StickerWallModal({ isOpen, onClose, imageUrl }) — default export

    • isOpen — boolean, controls rendering
    • onClose — callback
    • imageUrl — string (blob URL)
  • Step 1: Create frontend/src/components/StickerWallModal.jsx

import React, { useEffect } from 'react';

function StickerWallModal({ isOpen, onClose, imageUrl }) {
  useEffect(() => {
    if (!isOpen) return;
    const handleEscape = (e) => {
      if (e.key === 'Escape') onClose();
    };
    document.addEventListener('keydown', handleEscape);
    return () => document.removeEventListener('keydown', handleEscape);
  }, [isOpen, onClose]);

  if (!isOpen || !imageUrl) return null;

  const handleOverlayClick = (e) => {
    if (e.target === e.currentTarget) onClose();
  };

  return (
    <div
      className="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50 p-4"
      onClick={handleOverlayClick}
    >
      <div className="relative max-w-[90vw] max-h-[90vh]">
        <button
          onClick={onClose}
          className="absolute -top-3 -right-3 w-8 h-8 bg-gray-800 hover:bg-gray-700 text-white rounded-full flex items-center justify-center text-sm font-bold shadow-lg z-10 transition"
        >
          ×
        </button>
        <img
          src={imageUrl}
          alt="Sticker Wall"
          className="max-w-full max-h-[85vh] object-contain rounded-lg"
        />
      </div>
    </div>
  );
}

export default StickerWallModal;
  • Step 2: Commit
git add frontend/src/components/StickerWallModal.jsx
git commit -m "feat: add StickerWallModal component for full-size sticker wall viewing"

Task 4: StickerWallThumbnail Component

Files:

  • Create: frontend/src/components/StickerWallThumbnail.jsx

Interfaces:

  • Consumes: nothing external (presentational component)

  • Produces: StickerWallThumbnail({ imageUrl, loading, error, onClick }) — default export

    • imageUrl — string or null
    • loading — boolean
    • error — boolean
    • onClick — callback (for enlarging)
  • Step 1: Create frontend/src/components/StickerWallThumbnail.jsx

import React from 'react';

function StickerWallThumbnail({ imageUrl, loading, error, onClick }) {
  const baseClasses = 'rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden flex items-center justify-center bg-gray-100 dark:bg-gray-700/50 flex-shrink-0';

  if (loading) {
    return (
      <div className={`${baseClasses} w-[142px] h-[80px]`}>
        <svg className="animate-spin h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
          <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
          <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
        </svg>
      </div>
    );
  }

  if (error || !imageUrl) {
    return (
      <div className={`${baseClasses} w-[142px] h-[80px]`}>
        <span className="text-xs text-gray-400 dark:text-gray-500 text-center px-2">No stickers</span>
      </div>
    );
  }

  return (
    <button
      onClick={onClick}
      className={`${baseClasses} w-[142px] h-[80px] cursor-pointer hover:border-indigo-400 dark:hover:border-indigo-500 transition group`}
    >
      <img
        src={imageUrl}
        alt="Sticker Wall Preview"
        className="w-full h-full object-cover group-hover:opacity-90 transition"
      />
    </button>
  );
}

export default StickerWallThumbnail;
  • Step 2: Commit
git add frontend/src/components/StickerWallThumbnail.jsx
git commit -m "feat: add StickerWallThumbnail component for clickable sticker wall previews"

Task 5: Integrate Sticker Wall Preview into Picker

Files:

  • Modify: frontend/src/pages/Picker.jsx
    • Import block (top of file): add imports for useStickerWall, StickerWallThumbnail, StickerWallModal
    • State declarations (~line 35–38): add showStickerWallModal state
    • Sticker toggle card (~lines 892–931): restructure layout to add thumbnail + refresh button on the left

Interfaces:

  • Consumes:

    • useStickerWall(sessionId) from ../hooks/useStickerWall — returns { imageUrl, loading, error, refresh }
    • StickerWallThumbnail from ../components/StickerWallThumbnail
    • StickerWallModal from ../components/StickerWallModal
    • sessionId — already available in Picker's state (the active session's ID)
  • Produces: Updated Picker UI with sticker wall preview in the sticker toggle card

  • Step 1: Add imports to Picker.jsx

Add these three imports to the import block at the top of frontend/src/pages/Picker.jsx (after the existing component imports):

import { useStickerWall } from '../hooks/useStickerWall';
import StickerWallThumbnail from '../components/StickerWallThumbnail';
import StickerWallModal from '../components/StickerWallModal';
  • Step 2: Add hook call and modal state

In the Picker function body, near the other sticker-related state (after line 38, the prevStickerModeRef line), add:

const [showStickerWallModal, setShowStickerWallModal] = useState(false);

The active session is stored as activeSession (object, line 14). Add the hook call near the other sticker state:

const { imageUrl: stickerWallUrl, loading: stickerWallLoading, error: stickerWallError, refresh: refreshStickerWall } = useStickerWall(activeSession?.id);
  • Step 3: Restructure the sticker toggle card

Replace the sticker toggle card block (the {/* Sticker Visibility Toggle */} section, lines ~892–931) with this updated layout:

          {/* Sticker Visibility Toggle */}
          <div
            className={`flex items-center border rounded-lg p-3 mb-4 transition-all duration-300 ${
              stickerPulse
                ? 'border-indigo-400 dark:border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 animate-pulse'
                : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
            }`}
          >
            <StickerWallThumbnail
              imageUrl={stickerWallUrl}
              loading={stickerWallLoading}
              error={stickerWallError}
              onClick={() => stickerWallUrl && setShowStickerWallModal(true)}
            />
            <button
              onClick={refreshStickerWall}
              disabled={stickerWallLoading}
              className="ml-2 p-1.5 text-gray-400 hover:text-indigo-500 dark:text-gray-500 dark:hover:text-indigo-400 transition rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
              title="Refresh sticker wall"
            >
              <svg className={`w-4 h-4 ${stickerWallLoading ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
              </svg>
            </button>
            <div className="flex-1" />
            <span className="text-sm font-semibold text-gray-700 dark:text-gray-300 mr-3">Stickers</span>
            <div className="flex border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
              {[
                { value: 'auto', label: 'Auto', activeClass: 'bg-gray-500 text-white' },
                { value: 'show', label: 'Show', activeClass: 'bg-green-500 text-white' },
                { value: 'hide', label: 'Hide', activeClass: 'bg-red-500 text-white' },
              ].map((opt, i) => (
                <button
                  key={opt.value}
                  onClick={() => {
                    setStickerMode(opt.value);
                    if (opt.value === 'auto') {
                      stickerSendRef.current?.(pollActive ? 'show' : 'hide');
                    }
                    stickerSendRef.current?.(opt.value);
                  }}
                  disabled={!stickerWsReady}
                  className={`px-3 py-1.5 text-xs font-medium transition ${
                    i === 1 ? 'border-x border-gray-300 dark:border-gray-600 ' : ''
                  }${
                    stickerMode === opt.value
                      ? opt.activeClass
                      : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300'
                  }${
                    !stickerWsReady ? ' opacity-50 cursor-not-allowed' : ''
                  }`}
                >
                  {opt.label}
                </button>
              ))}
            </div>
          </div>
  • Step 4: Add the StickerWallModal render

Near the other modals in Picker (e.g. near the RoomCodeModal around line 877), add:

        <StickerWallModal
          isOpen={showStickerWallModal}
          onClose={() => setShowStickerWallModal(false)}
          imageUrl={stickerWallUrl}
        />
  • Step 5: Verify the app builds without errors

Run: cd frontend && npx vite build 2>&1 | tail -5 Expected: Build succeeds

  • Step 6: Commit
git add frontend/src/pages/Picker.jsx
git commit -m "feat: integrate sticker wall preview into Picker's sticker toggle card"

Task 6: Integrate Sticker Wall into SessionDetail

Files:

  • Modify: frontend/src/pages/SessionDetail.jsx
    • Import block (top of file): add imports for useStickerWall, StickerWallThumbnail, StickerWallModal
    • State declarations (~line 25): add showStickerWallModal state
    • JSX (~line 286, after the NotesSection card): add sticker wall section

Interfaces:

  • Consumes:

    • useStickerWall(sessionId) from ../hooks/useStickerWall — returns { imageUrl, loading, error }
    • StickerWallThumbnail from ../components/StickerWallThumbnail
    • StickerWallModal from ../components/StickerWallModal
    • id — from useParams(), already available in SessionDetail
  • Produces: Updated SessionDetail UI with sticker wall section between notes and games list

  • Step 1: Add imports to SessionDetail.jsx

Add these three imports to the import block at the top of frontend/src/pages/SessionDetail.jsx (after the existing component imports):

import { useStickerWall } from '../hooks/useStickerWall';
import StickerWallThumbnail from '../components/StickerWallThumbnail';
import StickerWallModal from '../components/StickerWallModal';
  • Step 2: Add hook call and modal state

Inside the SessionDetail function, after the existing state declarations (after line 25, the closingSession state), add:

const [showStickerWallModal, setShowStickerWallModal] = useState(false);
const { imageUrl: stickerWallUrl, loading: stickerWallLoading, error: stickerWallError } = useStickerWall(id);

Note: id comes from useParams() on line 11 and is the session ID.

  • Step 3: Add sticker wall section in JSX

After the NotesSection card closing </div> (line 286) and before the {showChatImport && ( block (line 288), insert:

      <div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
        <h2 className="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-3">Sticker Wall</h2>
        {stickerWallLoading ? (
          <div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
            <svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
            </svg>
            <span className="text-sm">Loading sticker wall...</span>
          </div>
        ) : stickerWallError || !stickerWallUrl ? (
          <p className="text-sm text-gray-500 dark:text-gray-400 italic">No sticker wall for this session</p>
        ) : (
          <StickerWallThumbnail
            imageUrl={stickerWallUrl}
            loading={false}
            error={false}
            onClick={() => setShowStickerWallModal(true)}
          />
        )}
      </div>

      <StickerWallModal
        isOpen={showStickerWallModal}
        onClose={() => setShowStickerWallModal(false)}
        imageUrl={stickerWallUrl}
      />
  • Step 4: Verify the app builds without errors

Run: cd frontend && npx vite build 2>&1 | tail -5 Expected: Build succeeds

  • Step 5: Commit
git add frontend/src/pages/SessionDetail.jsx
git commit -m "feat: add sticker wall section to SessionDetail view"