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

590 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```javascript
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`:
```javascript
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):
```javascript
const stickerwallsRoutes = require('./routes/stickerwalls');
```
Add to the route registration block (after line 37, the webhooks mount):
```javascript
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**
```bash
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`**
```jsx
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**
```bash
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`**
```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**
```bash
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`**
```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**
```bash
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):
```jsx
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:
```jsx
const [showStickerWallModal, setShowStickerWallModal] = useState(false);
```
The active session is stored as `activeSession` (object, line 14). Add the hook call near the other sticker state:
```jsx
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:
```jsx
{/* 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:
```jsx
<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**
```bash
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):
```jsx
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:
```jsx
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:
```jsx
<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**
```bash
git add frontend/src/pages/SessionDetail.jsx
git commit -m "feat: add sticker wall section to SessionDetail view"
```