Compare commits
15 Commits
c24223be5c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
20105a30a1
|
|||
|
1cf2a855b5
|
|||
|
9ac236575c
|
|||
|
9082d8d8c8
|
|||
|
edccb1e8d1
|
|||
|
0e83c42424
|
|||
|
125fd2dd9f
|
|||
|
dbf1f43e9e
|
|||
|
00673fd2c9
|
|||
|
6d9c4d50bb
|
|||
|
141296b35e
|
|||
|
8a37b674fe
|
|||
|
ab973cbf4f
|
|||
|
cea0e8c2be
|
|||
|
0149f0c146
|
@@ -137,6 +137,8 @@ The database will be automatically initialized and populated with games from `ga
|
||||
DB_PATH=./data/jackbox.db
|
||||
JWT_SECRET=your-secret-jwt-key
|
||||
ADMIN_KEY=admin123
|
||||
VOTE_APP_URL=https://vote-app.example.com
|
||||
VOTE_APP_API_KEY=your-vote-app-api-key
|
||||
```
|
||||
|
||||
4. **Start the backend server**
|
||||
@@ -184,6 +186,13 @@ If `admins.json` is not found, the app falls back to the `ADMIN_KEY` environment
|
||||
|
||||
To use a custom path for the admins file, set `ADMIN_CONFIG_PATH` in your environment.
|
||||
|
||||
### Sticker Wall Preview
|
||||
|
||||
The backend proxies sticker wall images from the vote-app. Set these in `backend/.env`:
|
||||
|
||||
- `VOTE_APP_URL` — base URL of the vote-app (e.g. `https://vote-app.example.com`)
|
||||
- `VOTE_APP_API_KEY` — API key for the sticker wall render endpoint
|
||||
|
||||
For Docker, uncomment the volume mount in `docker-compose.yml`:
|
||||
```yaml
|
||||
- ./backend/config/admins.json:/app/config/admins.json:ro
|
||||
|
||||
60
backend/routes/stickerwalls.js
Normal file
60
backend/routes/stickerwalls.js
Normal file
@@ -0,0 +1,60 @@
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -27,6 +27,7 @@ const statsRoutes = require('./routes/stats');
|
||||
const pickerRoutes = require('./routes/picker');
|
||||
const votesRoutes = require('./routes/votes');
|
||||
const webhooksRoutes = require('./routes/webhooks');
|
||||
const stickerwallsRoutes = require('./routes/stickerwalls');
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/games', gamesRoutes);
|
||||
@@ -35,6 +36,7 @@ app.use('/api/stats', statsRoutes);
|
||||
app.use('/api', pickerRoutes);
|
||||
app.use('/api/votes', votesRoutes);
|
||||
app.use('/api/webhooks', webhooksRoutes);
|
||||
app.use('/api/stickerwalls', stickerwallsRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
|
||||
@@ -142,6 +142,30 @@ class WebSocketManager {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'stickers.setMode':
|
||||
if (!clientInfo.authenticated) {
|
||||
this.sendError(ws, 'Not authenticated');
|
||||
return;
|
||||
}
|
||||
if (message.data?.mode && ['auto', 'show', 'hide'].includes(message.data.mode)) {
|
||||
clientInfo.subscribedSessions.forEach(sessionId => {
|
||||
this.broadcastEvent('stickers.setMode', message.data, sessionId);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'stickers.mode':
|
||||
if (!clientInfo.authenticated) {
|
||||
this.sendError(ws, 'Not authenticated');
|
||||
return;
|
||||
}
|
||||
if (message.mode && ['auto', 'show', 'hide'].includes(message.mode)) {
|
||||
clientInfo.subscribedSessions.forEach(sessionId => {
|
||||
this.broadcastEvent('stickers.mode', { mode: message.mode }, sessionId);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
this.sendError(ws, `Unknown message type: ${message.type}`);
|
||||
}
|
||||
|
||||
100
docs/external-downstream-stickerwalls.md
Normal file
100
docs/external-downstream-stickerwalls.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Sticker Wall Images — Integration Guide
|
||||
|
||||
The vote-app renders per-session sticker walls as 1920×1080 PNG images with transparent backgrounds. These can be loaded directly as `<img>` sources to display a session's sticker wall.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Retrieve Sticker Wall Image
|
||||
|
||||
```
|
||||
GET /stickerwalls/{sessionID}.png
|
||||
```
|
||||
|
||||
**Authentication:** None (public endpoint)
|
||||
|
||||
**Response:**
|
||||
- `200 OK` — image/png body, `Cache-Control: public, max-age=3600`
|
||||
- `404 Not Found` — no render has been performed for this session yet
|
||||
|
||||
**Usage:** Load directly as an image source:
|
||||
```html
|
||||
<img src="https://vote-app.example.com/stickerwalls/42.png" />
|
||||
```
|
||||
|
||||
### Trigger Render
|
||||
|
||||
```
|
||||
POST /api/stickerwalls/{sessionID}/render
|
||||
```
|
||||
|
||||
**Authentication:** Required — `X-API-Key` header with the upstream API key.
|
||||
|
||||
**Response:**
|
||||
- `200 OK` — image/png body with the rendered sticker wall
|
||||
- `403 Forbidden` — missing or invalid API key
|
||||
- `404 Not Found` — session has no placed stickers
|
||||
|
||||
**Response Headers:**
|
||||
- `X-Sticker-Wall-Rendered: true` — a fresh render was performed
|
||||
- `X-Sticker-Wall-Rendered: false` — served from cache (no new stickers since last render)
|
||||
|
||||
## Behavior
|
||||
|
||||
### Active Sessions
|
||||
|
||||
During an active session, stickers are placed by voters and stored in the database. The sticker wall image is **not** automatically re-rendered on each sticker purchase (to avoid thrashing).
|
||||
|
||||
To get a fresh image during an active session:
|
||||
1. Call `POST /api/stickerwalls/{sessionID}/render`
|
||||
2. If new stickers were placed since the last render, a fresh image is generated
|
||||
3. The response contains the image bytes directly
|
||||
|
||||
### Session End
|
||||
|
||||
When a session ends, the vote-app automatically renders the final sticker wall image. After this point:
|
||||
- The image is **immutable** — it will never be re-rendered
|
||||
- `GET /stickerwalls/{sessionID}.png` returns the final image directly
|
||||
- `POST /api/stickerwalls/{sessionID}/render` returns the cached final image (with `X-Sticker-Wall-Rendered: false`)
|
||||
|
||||
### Historical Sessions
|
||||
|
||||
Sessions that existed before this feature was deployed will be rendered on first request (via the render endpoint). Once rendered, they are cached and served normally.
|
||||
|
||||
## Suggested Integration
|
||||
|
||||
### Post-Session Retrieval (most common)
|
||||
|
||||
After a session ends, load the sticker wall for display or archival:
|
||||
|
||||
```javascript
|
||||
const sessionId = 42;
|
||||
const img = new Image();
|
||||
img.src = `https://vote-app.example.com/stickerwalls/${sessionId}.png`;
|
||||
img.onload = () => { /* display it */ };
|
||||
img.onerror = () => { /* no sticker wall for this session */ };
|
||||
```
|
||||
|
||||
### Active Session Preview
|
||||
|
||||
To show a "live preview" of the current sticker wall during a session:
|
||||
|
||||
```javascript
|
||||
async function refreshStickerWall(sessionId) {
|
||||
const res = await fetch(`/api/stickerwalls/${sessionId}/render`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-API-Key': API_KEY }
|
||||
});
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
document.getElementById('sticker-wall-preview').src = url;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Image Format
|
||||
|
||||
- **Dimensions:** 1920×1080 pixels
|
||||
- **Format:** PNG with alpha transparency
|
||||
- **Background:** Fully transparent — overlay on any background
|
||||
- **Sticker rendering:** Positioned, scaled, and rotated to match the live display
|
||||
162
docs/external-downtream-stickers.md
Normal file
162
docs/external-downtream-stickers.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Sticker Layer Visibility Control
|
||||
|
||||
Integration guide for the Jackbox Game Picker to toggle sticker layer visibility on the vote-app overlay.
|
||||
|
||||
## Overview
|
||||
|
||||
The vote-app overlay shows stickers that voters purchase throughout a game session. By default, stickers are visible whenever a poll is displayed and hidden between polls. This API allows the Game Picker host to override that behavior — showing accumulated stickers between rounds (a "sticker wall showcase") or hiding them during a poll for a cleaner view.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|--------|-------------------------------------------------|
|
||||
| `auto` | Default — stickers visible during polls, hidden between polls |
|
||||
| `show` | Force stickers visible regardless of poll state |
|
||||
| `hide` | Force stickers hidden regardless of poll state |
|
||||
|
||||
**Auto-reset:** When a new poll is generated, mode automatically resets to `auto`. The Game Picker receives a notification so its UI can update.
|
||||
|
||||
## WebSocket Integration (Primary)
|
||||
|
||||
### Sending: Set Mode
|
||||
|
||||
Send on the existing upstream WebSocket connection (`/api/sessions/live`):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "stickers.setMode",
|
||||
"data": {
|
||||
"mode": "show"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Valid values for `mode`: `"auto"`, `"show"`, `"hide"`.
|
||||
|
||||
### Receiving: Mode Changed
|
||||
|
||||
The vote-app sends this whenever the mode changes (including auto-resets):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "stickers.mode",
|
||||
"mode": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
**When you'll receive this:**
|
||||
- After you send `stickers.setMode` (confirmation)
|
||||
- When a new poll starts (mode resets to `auto`)
|
||||
- When a session starts/ends (mode resets to `auto`)
|
||||
- When the debug panel changes the mode
|
||||
|
||||
**Use this to keep your UI in sync.** If you show a toggle button, update its state whenever you receive this message.
|
||||
|
||||
## HTTP API (Fallback)
|
||||
|
||||
### Set Mode
|
||||
|
||||
```
|
||||
PUT /api/stickers/visibility
|
||||
```
|
||||
|
||||
**Headers:**
|
||||
- `Content-Type: application/json`
|
||||
- `X-API-Key: <UPSTREAM_API_KEY>`
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{ "mode": "show" }
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{ "mode": "show", "previousMode": "auto" }
|
||||
```
|
||||
|
||||
**Error responses:**
|
||||
- `403` — Invalid or missing API key
|
||||
- `400` — Invalid mode value (not `auto`/`show`/`hide`)
|
||||
|
||||
### Get Current Mode
|
||||
|
||||
```
|
||||
GET /api/stickers/visibility
|
||||
```
|
||||
|
||||
No auth required.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{ "mode": "auto" }
|
||||
```
|
||||
|
||||
Use this on startup to sync your UI state.
|
||||
|
||||
## Suggested UX
|
||||
|
||||
### Simple Toggle Button
|
||||
|
||||
A two-state toggle that alternates between `show` and `auto`:
|
||||
|
||||
```
|
||||
[Show Stickers] ← when mode is "auto" or "hide"
|
||||
[Hide Stickers] ← when mode is "show"
|
||||
```
|
||||
|
||||
When receiving `stickers.mode` with `"auto"`, reset the button to "Show Stickers" state.
|
||||
|
||||
### Three-State Control (Advanced)
|
||||
|
||||
If you prefer explicit control:
|
||||
|
||||
```
|
||||
( ) Auto — stickers follow poll visibility
|
||||
(•) Show — always visible
|
||||
( ) Hide — always hidden
|
||||
```
|
||||
|
||||
Highlight the current selection. Update when receiving `stickers.mode`.
|
||||
|
||||
### Handling Auto-Reset
|
||||
|
||||
When a poll starts, mode resets and you'll receive:
|
||||
```json
|
||||
{ "type": "stickers.mode", "mode": "auto" }
|
||||
```
|
||||
|
||||
Your UI should:
|
||||
1. Update the button/toggle state to reflect `auto`
|
||||
2. Optionally show a brief indicator ("Sticker mode reset")
|
||||
|
||||
## Example Flows
|
||||
|
||||
### Show stickers between games
|
||||
|
||||
1. Game ends, poll results shown, winner selected
|
||||
2. Host wants to show off sticker wall before next game
|
||||
3. Host clicks "Show Stickers" → sends `stickers.setMode` with `mode: "show"`
|
||||
4. Sticker layer becomes visible on the overlay
|
||||
5. Next poll starts → mode auto-resets to `auto`
|
||||
6. Game Picker receives `stickers.mode: "auto"` → updates button
|
||||
|
||||
### Hide stickers during a busy poll
|
||||
|
||||
1. Poll is active, stickers are distracting
|
||||
2. Host clicks "Hide Stickers" → sends `stickers.setMode` with `mode: "hide"`
|
||||
3. Sticker layer hides on the overlay
|
||||
4. Poll ends, new poll starts → mode auto-resets to `auto`
|
||||
5. Stickers show normally with the new poll
|
||||
|
||||
### Sync on reconnect
|
||||
|
||||
1. Game Picker reconnects to vote-app
|
||||
2. Call `GET /api/stickers/visibility` to get current mode
|
||||
3. Update UI to match
|
||||
|
||||
## Notes
|
||||
|
||||
- Stickers accumulate in the DOM regardless of visibility — toggling just shows/hides the layer
|
||||
- The sticker layer is at z-index 1, behind the poll overlay (z-index 9997)
|
||||
- Sticker data persists across server restarts (stored in SQLite)
|
||||
- Stickers are cleared on `session.started` and `session.ended` events
|
||||
266
docs/superpowers/plans/2026-08-23-sticker-visibility-toggle.md
Normal file
266
docs/superpowers/plans/2026-08-23-sticker-visibility-toggle.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# Sticker Visibility Toggle 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:** Add a 3-way segmented toggle (Auto/Show/Hide) to the Picker screen that lets admins control sticker layer visibility on the downstream vote-app overlay via WebSocket.
|
||||
|
||||
**Architecture:** State (`stickerMode`) and UI live in the `Picker` component. `SessionInfo` (which owns the WebSocket connection) handles inbound `stickers.mode` events via a setter prop and exposes a send function to `Picker` via a ref. This follows the existing pattern used for poll state, leading game, etc.
|
||||
|
||||
**Tech Stack:** React 18, Tailwind CSS, WebSocket (existing `/api/sessions/live` connection)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- All changes are in `frontend/src/pages/Picker.jsx` (contains both `Picker` and `SessionInfo` components)
|
||||
- Mode values are exactly `"auto"`, `"show"`, `"hide"` (matching the downstream API)
|
||||
- Default mode is `"auto"`
|
||||
- Follow existing code patterns — local `useState`, props passed to `SessionInfo`, Tailwind utility classes
|
||||
- No new dependencies
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `frontend/src/pages/Picker.jsx` | Modify | Add `stickerMode` state + `stickerSendRef` ref to `Picker`, render sticker toggle card in right column, add `setStickerMode` + `stickerSendRef` props to `SessionInfo`, handle `stickers.mode` WS events, populate send ref |
|
||||
|
||||
Single-file change. Both `Picker` and `SessionInfo` are defined in this file.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Wire sticker state and WebSocket plumbing
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/Picker.jsx:20-34` (Picker state declarations)
|
||||
- Modify: `frontend/src/pages/Picker.jsx:1282-1297` (SessionInfo props)
|
||||
- Modify: `frontend/src/pages/Picker.jsx:1304` (SessionInfo function signature)
|
||||
- Modify: `frontend/src/pages/Picker.jsx:1385-1396` (WS auth_success handler, populate stickerSendRef)
|
||||
- Modify: `frontend/src/pages/Picker.jsx:1470-1477` (WS message handler, add stickers.mode)
|
||||
- Modify: `frontend/src/pages/Picker.jsx:1487-1491` (WS onclose, clear stickerSendRef)
|
||||
- Modify: `frontend/src/pages/Picker.jsx:1498` (connectWs dependency array)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing (first task)
|
||||
- Produces:
|
||||
- `stickerMode` state (`'auto' | 'show' | 'hide'`) in Picker
|
||||
- `stickerSendRef` ref (`.current` is `(mode: string) => void` when WS connected, `null` otherwise)
|
||||
- `setStickerMode` and `stickerSendRef` passed as props to SessionInfo
|
||||
|
||||
- [ ] **Step 1: Add stickerMode state and stickerSendRef to Picker**
|
||||
|
||||
In `frontend/src/pages/Picker.jsx`, after line 34 (`const [sessionEnded, setSessionEnded] = useState(false);`), add:
|
||||
|
||||
```jsx
|
||||
const [stickerMode, setStickerMode] = useState('auto');
|
||||
const stickerSendRef = useRef(null);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Pass new props to SessionInfo**
|
||||
|
||||
In `frontend/src/pages/Picker.jsx`, find the `<SessionInfo` JSX (around line 1282). Add two new props after `setGameSource={setGameSource}`:
|
||||
|
||||
```jsx
|
||||
<SessionInfo
|
||||
sessionId={activeSession.id}
|
||||
onGamesUpdate={gamesUpdateTrigger}
|
||||
playingGame={playingGame}
|
||||
setPlayingGame={setPlayingGame}
|
||||
setHasPlayedGames={setHasPlayedGames}
|
||||
setLeadingGame={setLeadingGame}
|
||||
setPollActive={setPollActive}
|
||||
pollActiveRef={pollActiveRef}
|
||||
setPollResult={setPollResult}
|
||||
setPollEndingAt={setPollEndingAt}
|
||||
setShowEndPollOptions={setShowEndPollOptions}
|
||||
pollStartedAtRef={pollStartedAtRef}
|
||||
setSelectedGame={setSelectedGame}
|
||||
setGameSource={setGameSource}
|
||||
setStickerMode={setStickerMode}
|
||||
stickerSendRef={stickerSendRef}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Accept new props in SessionInfo function signature**
|
||||
|
||||
In `frontend/src/pages/Picker.jsx`, update the `SessionInfo` function signature (line 1304) to destructure the two new props:
|
||||
|
||||
```jsx
|
||||
function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, setHasPlayedGames, setLeadingGame, setPollActive, pollActiveRef, setPollResult, setPollEndingAt, setShowEndPollOptions, pollStartedAtRef, setSelectedGame, setGameSource, setStickerMode, stickerSendRef }) {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Populate stickerSendRef after WS authentication**
|
||||
|
||||
In the `connectWs` function, inside the `if (message.type === 'auth_success')` block (around line 1385), after the ping interval setup and before the `return;`, add the ref assignment:
|
||||
|
||||
```jsx
|
||||
if (message.type === 'auth_success') {
|
||||
console.log('[WebSocket] Authenticated, subscribing to session', sessionId);
|
||||
ws.send(JSON.stringify({ type: 'subscribe', sessionId: parseInt(sessionId) }));
|
||||
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
stickerSendRef.current = (mode) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'stickers.setMode', data: { mode } }));
|
||||
}
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Handle inbound stickers.mode WS events**
|
||||
|
||||
In the `ws.onmessage` handler, after the `game.dismissed` block (around line 1468) and before the `reloadEvents.includes` check, add:
|
||||
|
||||
```jsx
|
||||
if (message.type === 'stickers.mode') {
|
||||
setStickerMode(message.mode);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Clear stickerSendRef on WS disconnect**
|
||||
|
||||
In the `ws.onclose` handler (around line 1487), add the ref cleanup alongside the existing cleanup:
|
||||
|
||||
```jsx
|
||||
ws.onclose = () => {
|
||||
console.log('[WebSocket] Disconnected, reconnecting in 3s...');
|
||||
clearInterval(pingIntervalRef.current);
|
||||
stickerSendRef.current = null;
|
||||
reconnectTimeoutRef.current = setTimeout(connectWs, 3000);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Update connectWs dependency array**
|
||||
|
||||
Update the `useCallback` dependency array for `connectWs` (line 1498) to include the new dependencies:
|
||||
|
||||
```jsx
|
||||
}, [sessionId, token, loadGames, setPollActive, setPollResult, setPollEndingAt, setShowEndPollOptions, setLeadingGame, pollActiveRef, pollStartedAtRef, setSelectedGame, setGameSource, setStickerMode, stickerSendRef]);
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Verify no lint errors**
|
||||
|
||||
Run linter on `frontend/src/pages/Picker.jsx` and fix any issues introduced.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/Picker.jsx
|
||||
git commit -m "feat: wire sticker mode state and WebSocket plumbing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Render the sticker toggle UI with pulse animation
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/Picker.jsx:34-35` (add stickerPulse state, near other Picker state)
|
||||
- Modify: `frontend/src/pages/Picker.jsx:868-875` (insert toggle card above poll leader indicator in right column)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes:
|
||||
- `stickerMode` state (`'auto' | 'show' | 'hide'`) from Task 1
|
||||
- `stickerSendRef` ref from Task 1
|
||||
- `setStickerMode` setter from Task 1
|
||||
- Produces: Rendered sticker toggle card in Picker's right column
|
||||
|
||||
- [ ] **Step 1: Add stickerPulse state for auto-reset animation**
|
||||
|
||||
In `frontend/src/pages/Picker.jsx`, right after the `stickerSendRef` line added in Task 1 (after `const stickerSendRef = useRef(null);`), add:
|
||||
|
||||
```jsx
|
||||
const [stickerPulse, setStickerPulse] = useState(false);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add pulse trigger logic**
|
||||
|
||||
After the `stickerPulse` state declaration, add a `useEffect` that watches `stickerMode` for auto-reset events:
|
||||
|
||||
```jsx
|
||||
const prevStickerModeRef = useRef(stickerMode);
|
||||
useEffect(() => {
|
||||
if (stickerMode === 'auto' && prevStickerModeRef.current !== 'auto') {
|
||||
setStickerPulse(true);
|
||||
const timer = setTimeout(() => setStickerPulse(false), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
prevStickerModeRef.current = stickerMode;
|
||||
}, [stickerMode]);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Render the sticker toggle card**
|
||||
|
||||
In the right column's `{/* Results Panel */}` div, after the error display and **before** the `{/* Poll Leader Indicator */}` comment (line 875), insert the sticker toggle card:
|
||||
|
||||
```jsx
|
||||
{/* Sticker Visibility Toggle */}
|
||||
<div
|
||||
className={`flex items-center justify-between 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'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">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);
|
||||
stickerSendRef.current?.(opt.value);
|
||||
}}
|
||||
disabled={!stickerSendRef.current}
|
||||
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'
|
||||
}${
|
||||
!stickerSendRef.current ? ' opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify no lint errors**
|
||||
|
||||
Run linter on `frontend/src/pages/Picker.jsx` and fix any issues introduced.
|
||||
|
||||
- [ ] **Step 5: Manual smoke test**
|
||||
|
||||
Start the dev server and verify:
|
||||
|
||||
1. The sticker toggle card appears above the poll leader / poll control cards in the right column
|
||||
2. All three segments (Auto/Show/Hide) are clickable and highlight correctly
|
||||
3. Auto shows gray, Show shows green, Hide shows red when active
|
||||
4. Inactive segments show the muted/white style
|
||||
5. The card is compact and single-row on desktop
|
||||
|
||||
Run: `cd frontend && npm run dev`
|
||||
|
||||
Open the Picker page in a browser while logged in with an active session. Click each segment and confirm visual feedback.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/Picker.jsx
|
||||
git commit -m "feat: add sticker visibility toggle UI with pulse animation"
|
||||
```
|
||||
589
docs/superpowers/plans/2026-08-24-sticker-wall-preview.md
Normal file
589
docs/superpowers/plans/2026-08-24-sticker-wall-preview.md
Normal file
@@ -0,0 +1,589 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -0,0 +1,122 @@
|
||||
# Sticker Visibility Toggle — Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
Add a 3-way segmented toggle to the Picker's Game Session screen that lets admins control sticker layer visibility on the downstream vote-app overlay. The three modes are **Auto** (default), **Show**, and **Hide**, matching the downstream API contract defined in `docs/external-downstream-stickers.md`.
|
||||
|
||||
## Motivation
|
||||
|
||||
The vote-app overlay shows stickers that voters purchase during a session. By default, stickers follow poll state (visible during polls, hidden between). Admins need the ability to override this — force-showing stickers between rounds for a "sticker wall showcase" or force-hiding them during a busy poll for a cleaner view.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `auto` | Default — stickers visible during polls, hidden between polls |
|
||||
| `show` | Force stickers visible regardless of poll state |
|
||||
| `hide` | Force stickers hidden regardless of poll state |
|
||||
|
||||
When a new poll starts, the downstream vote-app auto-resets mode to `auto` and sends a `stickers.mode` event.
|
||||
|
||||
## Architecture
|
||||
|
||||
### State ownership
|
||||
|
||||
- `stickerMode` state (`useState('auto')`) lives in the `Picker` component, where the toggle UI renders.
|
||||
- A `stickerSendRef` ref is created in `Picker` and passed to `SessionInfo`, which populates it with a send function once the WebSocket connection is authenticated.
|
||||
|
||||
### Props
|
||||
|
||||
`SessionInfo` receives two new props (following the existing pattern for `setPollActive`, `setLeadingGame`, etc.):
|
||||
|
||||
- `setStickerMode` — setter called when inbound `stickers.mode` WS events arrive.
|
||||
- `stickerSendRef` — ref that SessionInfo populates with `(mode) => ws.send(...)`.
|
||||
|
||||
### WebSocket integration
|
||||
|
||||
All communication uses the existing `/api/sessions/live` WebSocket connection managed by `SessionInfo.connectWs`.
|
||||
|
||||
**Receiving (`stickers.mode` events):**
|
||||
|
||||
```javascript
|
||||
if (message.type === 'stickers.mode') {
|
||||
setStickerMode(message.mode);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
Added alongside the existing `poll.*` and `game.*` handlers in the `ws.onmessage` callback.
|
||||
|
||||
**Sending (`stickers.setMode` commands):**
|
||||
|
||||
After successful auth and subscribe, SessionInfo populates the ref:
|
||||
|
||||
```javascript
|
||||
stickerSendRef.current = (mode) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'stickers.setMode', data: { mode } }));
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
The ref is cleared on disconnect/cleanup.
|
||||
|
||||
**No HTTP fallback.** The default `auto` state is correct on fresh load, and the WS connection handles all sync from that point.
|
||||
|
||||
## UI Design
|
||||
|
||||
### Placement
|
||||
|
||||
The sticker toggle renders in Picker's right column (`md:col-span-2`), **above** the poll leader indicator and poll control cards. It is always visible when an active session exists, regardless of poll state.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Stickers [Auto] [Show] [Hide] │
|
||||
└─────────────────────────────────────────────┘
|
||||
↕ poll leader indicator (conditional)
|
||||
↕ currently playing card (conditional)
|
||||
↕ poll control card (conditional)
|
||||
↕ SessionInfo games list
|
||||
```
|
||||
|
||||
### Control style
|
||||
|
||||
A compact single-row card with:
|
||||
- **Label** ("Stickers") on the left.
|
||||
- **3-way segmented control** on the right, matching the Drawing/Family Friendly filter pattern (three adjacent buttons, active one highlighted).
|
||||
|
||||
### Segment colors
|
||||
|
||||
| Segment | Active background |
|
||||
|---------|-------------------|
|
||||
| Auto | Neutral gray (`bg-gray-500`) |
|
||||
| Show | Green (`bg-green-500`) |
|
||||
| Hide | Red (`bg-red-500`) |
|
||||
|
||||
Inactive segments use a muted/transparent style with border, same as the existing filter toggles.
|
||||
|
||||
### Auto-reset pulse
|
||||
|
||||
When a `stickers.mode` event arrives with `mode: "auto"` and the current local state is not already `auto`, a brief CSS pulse animation plays on the toggle card:
|
||||
|
||||
- A gentle border/background highlight flash that fades over ~2 seconds.
|
||||
- Implemented via a CSS `@keyframes` animation toggled by a transient state flag.
|
||||
- The flag is set on receiving an auto-reset event and cleared after the animation completes.
|
||||
|
||||
### Interaction behavior
|
||||
|
||||
- Clicking a segment calls `stickerSendRef.current(mode)` and optimistically updates local state.
|
||||
- If the subsequent `stickers.mode` event returns a different value, state corrects to match.
|
||||
- The toggle is visually disabled (reduced opacity, no pointer events) if the WebSocket is not connected.
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/pages/Picker.jsx` | Add `stickerMode` state, `stickerSendRef` ref, render sticker toggle card, pass new props to `SessionInfo` |
|
||||
| `frontend/src/pages/Picker.jsx` (`SessionInfo`) | Accept `setStickerMode` and `stickerSendRef` props, handle `stickers.mode` WS events, populate send ref |
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
- **In scope:** Frontend toggle UI + WS message send/receive in the game picker.
|
||||
- **Out of scope:** Backend relay of `stickers.setMode` messages to the vote-app (assumed handled by existing infrastructure or a separate task). Vote-app sticker rendering (already implemented downstream).
|
||||
158
docs/superpowers/specs/2026-08-24-sticker-wall-preview-design.md
Normal file
158
docs/superpowers/specs/2026-08-24-sticker-wall-preview-design.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Sticker Wall Preview — Design Spec
|
||||
|
||||
Display sticker wall images in two places: as a live preview in the Picker's sticker toggle card, and as a historical record in the SessionDetail view.
|
||||
|
||||
## Backend — Proxy Endpoints
|
||||
|
||||
Two new Express routes in `backend/routes/stickerwalls.js`, both behind JWT auth middleware.
|
||||
|
||||
### `GET /api/stickerwalls/:sessionId`
|
||||
|
||||
Proxies to the vote-app's `GET /stickerwalls/{sessionID}.png`.
|
||||
|
||||
- Streams the PNG response through to the client.
|
||||
- Passes through status codes: 200 with `image/png` body, 404 if no render exists.
|
||||
- Passes through `Cache-Control` header from upstream.
|
||||
|
||||
### `POST /api/stickerwalls/:sessionId/render`
|
||||
|
||||
Proxies to the vote-app's `POST /api/stickerwalls/{sessionID}/render`.
|
||||
|
||||
- Injects the `X-API-Key` header server-side (from env var).
|
||||
- Streams back the PNG response.
|
||||
- Forwards the `X-Sticker-Wall-Rendered` response header (indicates fresh render vs cached).
|
||||
- Passes through status codes: 200 with image, 404 if session has no placed stickers.
|
||||
|
||||
### Configuration
|
||||
|
||||
Two new environment variables:
|
||||
|
||||
- `VOTE_APP_URL` — base URL of the vote-app (e.g. `https://vote-app.example.com`)
|
||||
- `VOTE_APP_API_KEY` — API key for the render endpoint
|
||||
|
||||
## Frontend — Shared Components
|
||||
|
||||
### `StickerWallThumbnail` (`frontend/src/components/StickerWallThumbnail.jsx`)
|
||||
|
||||
Clickable image thumbnail for sticker walls. Used in both Picker and SessionDetail.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `imageUrl` — blob URL or null. When set, displays this image.
|
||||
- `loading` — boolean, shows a spinner overlay while fetching.
|
||||
- `error` — boolean, shows placeholder state ("No sticker wall").
|
||||
- `onClick` — callback for enlarging (opens modal).
|
||||
|
||||
**Rendering:**
|
||||
|
||||
- Fixed small size, 16:9 aspect ratio. Scaled-down from 1920×1080.
|
||||
- Rounded corners, subtle border matching surrounding card style.
|
||||
- Three visual states: loading (spinner), error/empty (muted placeholder), loaded (image).
|
||||
|
||||
### `StickerWallModal` (`frontend/src/components/StickerWallModal.jsx`)
|
||||
|
||||
Full-size image viewer modal. Used in both Picker and SessionDetail.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `isOpen` — boolean controlling visibility.
|
||||
- `onClose` — callback to close.
|
||||
- `imageUrl` — blob URL of the sticker wall image.
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- Displays the sticker wall image at full/large size, constrained to viewport (`max-w-screen`, `max-h-screen`, `object-contain`).
|
||||
- PNG with transparent background rendered over a dark backdrop so stickers are visible.
|
||||
- Closes on: Escape key, overlay click, explicit close button (top-right ×).
|
||||
- Follows existing Tailwind modal pattern (`fixed inset-0`, `z-50`), built as a reusable component like `RoomCodeModal`.
|
||||
|
||||
## Shared Fetch Logic — `useStickerWall` Hook
|
||||
|
||||
`frontend/src/hooks/useStickerWall.js` — custom hook used by both Picker and SessionDetail.
|
||||
|
||||
**Input:** `sessionId` (triggers fetch when set/changed).
|
||||
|
||||
**Returns:** `{ imageUrl, loading, error, refresh }` where `refresh()` manually calls the render endpoint.
|
||||
|
||||
**Fetch sequence (runs on mount / sessionId change):**
|
||||
|
||||
1. `GET /api/stickerwalls/:sessionId` — try the cached image.
|
||||
2. If 404 → `POST /api/stickerwalls/:sessionId/render` — attempt to generate one.
|
||||
3. If that also 404s (no stickers exist) → set error state.
|
||||
|
||||
Both endpoints return image bytes. The hook fetches as blob, creates a blob URL via `URL.createObjectURL`, and revokes the previous URL to prevent memory leaks. Cleanup on unmount revokes the current URL.
|
||||
|
||||
**`refresh()` method:** Calls `POST /api/stickerwalls/:sessionId/render` directly (skipping the GET), updates `imageUrl` with the result. Used only by the Picker's refresh button.
|
||||
|
||||
This catches three cases:
|
||||
- Sessions with a final rendered image (normal post-feature sessions) — served from step 1.
|
||||
- Sessions with sticker data but no render yet (played between wall archival and retrieval deployment) — rendered in step 2.
|
||||
- Sessions with no stickers at all — error state after step 2 fails.
|
||||
|
||||
## Frontend — Picker (Live Session Sticker Card)
|
||||
|
||||
The existing sticker toggle card in `Picker.jsx` (~line 892) is expanded to include the sticker wall preview.
|
||||
|
||||
### Layout (left to right)
|
||||
|
||||
1. **Thumbnail** — `StickerWallThumbnail` on the left side. Sized to fit the card height (~80–90px tall, ~142–160px wide at 16:9). Rounded corners, subtle border.
|
||||
2. **Refresh button** — Immediately right of the thumbnail. Small, subtle icon button (refresh/sync icon). Muted styling (`text-gray-400 hover:text-indigo-500`) so it doesn't compete with the mode toggle. Visually separated from the segmented control.
|
||||
3. **Flex spacer** — Pushes the toggle control to the right edge.
|
||||
4. **"Stickers" label + auto/show/hide segmented control** — Unchanged, stays on the right.
|
||||
|
||||
### Fetch behavior
|
||||
|
||||
- On session load (when `sessionId` is available), run the shared fetch logic.
|
||||
- The refresh button calls `POST /api/stickerwalls/:sessionId/render` and updates the thumbnail with the response. Shows a spinner on the button while rendering.
|
||||
|
||||
### Click to enlarge
|
||||
|
||||
Clicking the thumbnail opens `StickerWallModal` with the current image.
|
||||
|
||||
### State
|
||||
|
||||
Managed by the `useStickerWall(sessionId)` hook. Picker destructures `{ imageUrl, loading, error, refresh }` — no additional local state needed beyond the modal open/close boolean.
|
||||
|
||||
## Frontend — SessionDetail (History View)
|
||||
|
||||
A new "Sticker Wall" section in `SessionDetail.jsx`, positioned after the notes section and before the games list.
|
||||
|
||||
### Card layout
|
||||
|
||||
- Section heading "Sticker Wall" matching existing heading style (Notes, Games, etc.).
|
||||
- Contains `StickerWallThumbnail`, slightly larger than in Picker (~120px tall).
|
||||
- No refresh button — historical sessions have immutable final images; the one-time render-on-404 from the shared fetch logic handles the edge case of pre-existing sessions.
|
||||
|
||||
### Fetch behavior
|
||||
|
||||
On load, run the shared fetch logic (GET, then POST on 404, then placeholder on second 404).
|
||||
|
||||
### Placeholder state
|
||||
|
||||
If no sticker wall exists (both GET and POST return 404), the card displays "No sticker wall for this session" in muted text.
|
||||
|
||||
### Click to enlarge
|
||||
|
||||
Clicking the thumbnail opens `StickerWallModal`.
|
||||
|
||||
### Active sessions in SessionDetail
|
||||
|
||||
If the session is still active (`is_active === 1`), the sticker wall section still appears with read-only fetch-on-load behavior. No refresh button — the Picker is the intended live interface.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Network errors on proxy requests:** Show a brief toast or inline error state in the thumbnail. Don't block the rest of the page.
|
||||
- **Render endpoint returns cached (X-Sticker-Wall-Rendered: false):** No special UI treatment — just display the image. The admin can infer from visual inspection whether anything changed.
|
||||
- **Blob URL cleanup:** Revoke object URLs on unmount and when replaced by a new fetch, to avoid memory leaks.
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `backend/routes/stickerwalls.js` | New file — proxy endpoints |
|
||||
| `backend/server.js` (or wherever routes mount) | Register stickerwalls routes |
|
||||
| `frontend/src/hooks/useStickerWall.js` | New file — shared fetch/render hook |
|
||||
| `frontend/src/components/StickerWallThumbnail.jsx` | New file — reusable thumbnail |
|
||||
| `frontend/src/components/StickerWallModal.jsx` | New file — reusable full-size modal |
|
||||
| `frontend/src/pages/Picker.jsx` | Expand sticker card with thumbnail, refresh button, modal |
|
||||
| `frontend/src/pages/SessionDetail.jsx` | Add sticker wall section after notes |
|
||||
41
frontend/src/components/StickerWallModal.jsx
Normal file
41
frontend/src/components/StickerWallModal.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
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;
|
||||
39
frontend/src/components/StickerWallThumbnail.jsx
Normal file
39
frontend/src/components/StickerWallThumbnail.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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;
|
||||
@@ -2,7 +2,7 @@ export const branding = {
|
||||
app: {
|
||||
name: 'HSO Jackbox Game Picker',
|
||||
shortName: 'Jackbox Game Picker',
|
||||
version: '0.7.13 - Pokémon-Go-To-The-Polls Edition',
|
||||
version: '8.1.2 - Stickers That Stick Edition',
|
||||
description: 'Spicing up Hyper Spaceout game nights!',
|
||||
},
|
||||
meta: {
|
||||
|
||||
97
frontend/src/hooks/useStickerWall.js
Normal file
97
frontend/src/hooks/useStickerWall.js
Normal file
@@ -0,0 +1,97 @@
|
||||
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 generationRef = useRef(0);
|
||||
|
||||
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 refresh = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
const generation = generationRef.current;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const res = await api.post(`/stickerwalls/${sessionId}/render`, null, { responseType: 'blob' });
|
||||
if (generation !== generationRef.current) return;
|
||||
setBlobUrl(res.data);
|
||||
} catch {
|
||||
if (generation !== generationRef.current) return;
|
||||
if (!urlRef.current) {
|
||||
setError(true);
|
||||
}
|
||||
} finally {
|
||||
if (generation === generationRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [sessionId, setBlobUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
generationRef.current += 1;
|
||||
const generation = generationRef.current;
|
||||
|
||||
if (!sessionId) {
|
||||
revokeUrl();
|
||||
setImageUrl(null);
|
||||
setError(false);
|
||||
return revokeUrl;
|
||||
}
|
||||
|
||||
const fetchWall = async (id) => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const res = await api.get(`/stickerwalls/${id}`, { responseType: 'blob' });
|
||||
if (generation !== generationRef.current) return;
|
||||
setBlobUrl(res.data);
|
||||
} catch (err) {
|
||||
if (generation !== generationRef.current) return;
|
||||
if (err.response?.status === 404) {
|
||||
try {
|
||||
const renderRes = await api.post(`/stickerwalls/${id}/render`, null, { responseType: 'blob' });
|
||||
if (generation !== generationRef.current) return;
|
||||
setBlobUrl(renderRes.data);
|
||||
} catch {
|
||||
if (generation !== generationRef.current) return;
|
||||
revokeUrl();
|
||||
setImageUrl(null);
|
||||
setError(true);
|
||||
}
|
||||
} else {
|
||||
revokeUrl();
|
||||
setImageUrl(null);
|
||||
setError(true);
|
||||
}
|
||||
} finally {
|
||||
if (generation === generationRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchWall(sessionId);
|
||||
|
||||
return () => {
|
||||
revokeUrl();
|
||||
};
|
||||
}, [sessionId, setBlobUrl, revokeUrl]);
|
||||
|
||||
return { imageUrl, loading, error, refresh };
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import GamePoolModal from '../components/GamePoolModal';
|
||||
import RoomCodeModal from '../components/RoomCodeModal';
|
||||
import { formatLocalTime } from '../utils/dateUtils';
|
||||
import PopularityBadge from '../components/PopularityBadge';
|
||||
import { useStickerWall } from '../hooks/useStickerWall';
|
||||
import StickerWallThumbnail from '../components/StickerWallThumbnail';
|
||||
import StickerWallModal from '../components/StickerWallModal';
|
||||
|
||||
function Picker() {
|
||||
const { isAuthenticated, loading: authLoading, token } = useAuth();
|
||||
@@ -32,6 +35,25 @@ function Picker() {
|
||||
const [error, setError] = useState('');
|
||||
const [showPopularity, setShowPopularity] = useState(true);
|
||||
const [sessionEnded, setSessionEnded] = useState(false);
|
||||
const [stickerMode, setStickerMode] = useState('auto');
|
||||
const stickerSendRef = useRef(null);
|
||||
const [stickerWsReady, setStickerWsReady] = useState(false);
|
||||
const [stickerPulse, setStickerPulse] = useState(false);
|
||||
const prevStickerModeRef = useRef(stickerMode);
|
||||
const [showStickerWallModal, setShowStickerWallModal] = useState(false);
|
||||
const { imageUrl: stickerWallUrl, loading: stickerWallLoading, error: stickerWallError, refresh: refreshStickerWall } = useStickerWall(activeSession?.id);
|
||||
useEffect(() => {
|
||||
if (stickerMode === 'auto' && prevStickerModeRef.current !== 'auto') {
|
||||
setStickerPulse(true);
|
||||
const timer = setTimeout(() => setStickerPulse(false), 2000);
|
||||
prevStickerModeRef.current = stickerMode;
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (stickerMode !== 'auto') {
|
||||
setStickerPulse(false);
|
||||
}
|
||||
prevStickerModeRef.current = stickerMode;
|
||||
}, [stickerMode]);
|
||||
|
||||
// Filters
|
||||
const [playerCount, setPlayerCount] = useState('');
|
||||
@@ -864,6 +886,12 @@ function Picker() {
|
||||
gameName={pendingGameAction?.game?.title}
|
||||
/>
|
||||
|
||||
<StickerWallModal
|
||||
isOpen={showStickerWallModal}
|
||||
onClose={() => setShowStickerWallModal(false)}
|
||||
imageUrl={stickerWallUrl}
|
||||
/>
|
||||
|
||||
{/* Results Panel */}
|
||||
<div className="md:col-span-2">
|
||||
{error && (
|
||||
@@ -872,6 +900,64 @@ function Picker() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Poll Leader Indicator */}
|
||||
{leadingGame && (
|
||||
<div className="bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 rounded-lg p-3 sm:p-4 mb-4">
|
||||
@@ -1294,6 +1380,9 @@ function Picker() {
|
||||
pollStartedAtRef={pollStartedAtRef}
|
||||
setSelectedGame={setSelectedGame}
|
||||
setGameSource={setGameSource}
|
||||
setStickerMode={setStickerMode}
|
||||
stickerSendRef={stickerSendRef}
|
||||
setStickerWsReady={setStickerWsReady}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1301,7 +1390,7 @@ function Picker() {
|
||||
);
|
||||
}
|
||||
|
||||
function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, setHasPlayedGames, setLeadingGame, setPollActive, pollActiveRef, setPollResult, setPollEndingAt, setShowEndPollOptions, pollStartedAtRef, setSelectedGame, setGameSource }) {
|
||||
function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, setHasPlayedGames, setLeadingGame, setPollActive, pollActiveRef, setPollResult, setPollEndingAt, setShowEndPollOptions, pollStartedAtRef, setSelectedGame, setGameSource, setStickerMode, stickerSendRef, setStickerWsReady }) {
|
||||
const { isAuthenticated, token } = useAuth();
|
||||
const [games, setGames] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -1392,6 +1481,14 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, se
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
stickerSendRef.current = (mode) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'stickers.setMode', data: { mode } }));
|
||||
}
|
||||
};
|
||||
setStickerWsReady(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1467,6 +1564,11 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, se
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'stickers.mode') {
|
||||
setStickerMode(message.data.mode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (reloadEvents.includes(message.type)) {
|
||||
console.log(`[WebSocket] ${message.type}:`, message.data);
|
||||
if (message.type === 'game.added') {
|
||||
@@ -1487,6 +1589,8 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, se
|
||||
ws.onclose = () => {
|
||||
console.log('[WebSocket] Disconnected, reconnecting in 3s...');
|
||||
clearInterval(pingIntervalRef.current);
|
||||
stickerSendRef.current = null;
|
||||
setStickerWsReady(false);
|
||||
reconnectTimeoutRef.current = setTimeout(connectWs, 3000);
|
||||
};
|
||||
|
||||
@@ -1495,7 +1599,7 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, se
|
||||
console.error('[WebSocket] Failed to connect:', error);
|
||||
reconnectTimeoutRef.current = setTimeout(connectWs, 3000);
|
||||
}
|
||||
}, [sessionId, token, loadGames, setPollActive, setPollResult, setPollEndingAt, setShowEndPollOptions, setLeadingGame, pollActiveRef, pollStartedAtRef, setSelectedGame, setGameSource]);
|
||||
}, [sessionId, token, loadGames, setPollActive, setPollResult, setPollEndingAt, setShowEndPollOptions, setLeadingGame, pollActiveRef, pollStartedAtRef, setSelectedGame, setGameSource, setStickerMode, stickerSendRef, setStickerWsReady]);
|
||||
|
||||
useEffect(() => {
|
||||
connectWs();
|
||||
@@ -1503,6 +1607,7 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, se
|
||||
return () => {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
clearInterval(pingIntervalRef.current);
|
||||
setStickerWsReady(false);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
|
||||
@@ -6,6 +6,9 @@ import { useToast } from '../components/Toast';
|
||||
import api from '../api/axios';
|
||||
import { formatLocalDateTime, formatLocalTime, isSunday } from '../utils/dateUtils';
|
||||
import PopularityBadge from '../components/PopularityBadge';
|
||||
import { useStickerWall } from '../hooks/useStickerWall';
|
||||
import StickerWallThumbnail from '../components/StickerWallThumbnail';
|
||||
import StickerWallModal from '../components/StickerWallModal';
|
||||
|
||||
function SessionDetail() {
|
||||
const { id } = useParams();
|
||||
@@ -23,6 +26,8 @@ function SessionDetail() {
|
||||
const [showDeleteSessionConfirm, setShowDeleteSessionConfirm] = useState(false);
|
||||
const [showChatImport, setShowChatImport] = useState(false);
|
||||
const [closingSession, setClosingSession] = useState(false);
|
||||
const [showStickerWallModal, setShowStickerWallModal] = useState(false);
|
||||
const { imageUrl: stickerWallUrl, loading: stickerWallLoading, error: stickerWallError } = useStickerWall(id);
|
||||
|
||||
const loadSession = useCallback(async () => {
|
||||
try {
|
||||
@@ -285,6 +290,34 @@ function SessionDetail() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
/>
|
||||
|
||||
{showChatImport && (
|
||||
<div className="mb-6">
|
||||
<ChatImportPanel
|
||||
|
||||
Reference in New Issue
Block a user