diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e98dfec2..348c79bf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,7 +1,7 @@ # Plot Twists - Application Architecture & Documentation -**Last Updated:** 2026-01-23 -**Version:** 1.0 +**Last Updated:** 2026-02-01 +**Version:** 1.4 **Status:** Active Development --- @@ -1231,9 +1231,13 @@ interface CardPack { - **Standard Pack**: Built-in default (200+ characters, 70+ settings, 60+ circumstances) - **Custom Packs**: User-created and persisted to `data/cardpacks.json` -- **Example Packs**: "Office Comedy" and "Sci-Fi Adventures" included +- **Example Packs**: "Office Comedy" and "Sci-Fi Adventures" (deterministic IDs for persistence) - **Pack Selection**: Host chooses pack in lobby -- **Rating System**: Users can rate packs (1-5 stars) +- **Rating System**: Interactive star rating (1-5 stars) with hover states +- **Pack Browser**: Full modal for discovering community packs with search, filter, and sort +- **Pack Editor**: Edit existing custom packs (3-step wizard) +- **Pack Deletion**: Delete custom packs with confirmation modal +- **Player Visibility**: Players see selected pack name in lobby #### Socket Events @@ -1243,11 +1247,21 @@ interface CardPack { | `select_card_pack` | Client → Server | Choose pack for room | | `card_pack_selected` | Server → Client | Broadcast selection | | `create_card_pack` | Client → Server | Create new pack | +| `update_card_pack` | Client → Server | Update existing pack | +| `delete_card_pack` | Client → Server | Delete a pack | | `rate_card_pack` | Client → Server | Rate a pack | - -#### Files Added -- `server/services/cardpack.service.ts` - Pack CRUD operations -- `components/CardPackSelector.tsx` - Pack browser UI +| `search_card_packs` | Client → Server | Search packs by query | +| `get_featured_packs` | Client → Server | Get top-rated packs | +| `get_card_pack` | Client → Server | Get pack details by ID | + +#### Files Added/Modified +- `server/services/cardpack.service.ts` - Pack CRUD operations (enhanced with deterministic IDs) +- `components/CardPackSelector.tsx` - Pack browser UI (enhanced with edit/delete/rate) +- `components/CardPackCreator.tsx` - 3-step pack creation wizard +- `components/CardPackEditor.tsx` - Edit existing packs +- `components/CardPackBrowser.tsx` - Full discovery modal with search/filter/sort +- `components/StarRating.tsx` - Interactive star rating component +- `components/DeleteConfirmModal.tsx` - Confirmation dialog for deletions - `data/cardpacks.json` - Pack persistence --- @@ -1396,6 +1410,7 @@ interface RoomSettings { | Date | Version | Changes | Author | |------|---------|---------|--------| +| 2026-02-01 | 1.4 | Card Pack Creator feature completion: CardPackEditor, DeleteConfirmModal, StarRating, CardPackBrowser components; search/featured/edit/delete socket events | Claude | | 2026-01-24 | 1.3 | Added 4 major features: Audience Interaction System, AI Script Customization Engine, Custom Card Pack Creator, Voice & Audio Integration | Claude | | 2026-01-23 | 1.2 | Enhanced card selection UX with "Shuffle All", progress indicators, haptic feedback, and selection preview | Claude | | 2026-01-23 | 1.1 | Added security enhancements, rate limiting, testing infrastructure, PWA support, code organization | Claude | diff --git a/app/host/page.tsx b/app/host/page.tsx index 505a57e0..2bf931fb 100644 --- a/app/host/page.tsx +++ b/app/host/page.tsx @@ -487,6 +487,7 @@ export default function HostPage() { roomCode={roomCode} selectedPackId={selectedPackId} onSelect={setSelectedPackId} + showCreateButton={true} /> ('PLAYER') + const [selectedPackName, setSelectedPackName] = useState(null) const previousSpeaker = React.useRef('') useEffect(() => { @@ -106,6 +107,18 @@ function JoinPageContent() { toast.error(errorMsg) setError(errorMsg) }) + socket.on('card_pack_selected', (packId: string) => { + // Display a friendly name based on pack ID + if (packId === 'standard') { + setSelectedPackName('Standard Pack') + } else if (packId === 'example-office-comedy') { + setSelectedPackName('Office Comedy') + } else if (packId === 'example-scifi-adventures') { + setSelectedPackName('Sci-Fi Adventures') + } else { + setSelectedPackName('Custom Pack') + } + }) return () => { socket.off('players_update') socket.off('game_state_change') @@ -115,6 +128,7 @@ function JoinPageContent() { socket.off('sync_teleprompter') socket.off('game_over') socket.off('error') + socket.off('card_pack_selected') } }, [socket, isConnected, selection, myRole]) @@ -302,11 +316,24 @@ function JoinPageContent() {

{myRole === 'SPECTATOR' ? 'Spectator Mode' : "You're In!"}

-

+

{myRole === 'SPECTATOR' ? 'Sit back and enjoy the show! You can vote at the end.' : 'Waiting for game to start...'}

+ {selectedPackName && ( + + 📦 + + {selectedPackName} + + + )}
{players.map((player) => (
void + onSelectPack: (packId: string) => void + currentPackId?: string +} + +type SortOption = 'rating' | 'downloads' | 'newest' | 'name' + +const THEMES = [ + { value: 'all', label: 'All', emoji: '🎭' }, + { value: 'office', label: 'Office', emoji: '💼' }, + { value: 'scifi', label: 'Sci-Fi', emoji: '🚀' }, + { value: 'fantasy', label: 'Fantasy', emoji: '🧙' }, + { value: 'horror', label: 'Horror', emoji: '👻' }, + { value: 'romance', label: 'Romance', emoji: '💕' }, + { value: 'action', label: 'Action', emoji: '💥' }, + { value: 'comedy', label: 'Comedy', emoji: '😂' }, + { value: 'mixed', label: 'Mixed', emoji: '🎲' } +] + +export function CardPackBrowser({ isOpen, onClose, onSelectPack, currentPackId }: CardPackBrowserProps) { + const { socket } = useSocket() + const [searchQuery, setSearchQuery] = useState('') + const [selectedTheme, setSelectedTheme] = useState('all') + const [sortBy, setSortBy] = useState('rating') + const [packs, setPacks] = useState([]) + const [featuredPacks, setFeaturedPacks] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Fetch all packs + const fetchPacks = useCallback(() => { + if (!socket) return + + setLoading(true) + socket.emit('list_card_packs', (response) => { + setLoading(false) + if (response.success && response.packs) { + setPacks(response.packs) + } else { + setError(response.error || 'Failed to load packs') + } + }) + }, [socket]) + + // Fetch featured packs + const fetchFeatured = useCallback(() => { + if (!socket) return + + socket.emit('get_featured_packs', 5, (response) => { + if (response.success && response.packs) { + setFeaturedPacks(response.packs) + } + }) + }, [socket]) + + // Search packs + const searchPacks = useCallback(() => { + if (!socket || !searchQuery.trim()) { + fetchPacks() + return + } + + setLoading(true) + socket.emit('search_card_packs', searchQuery, (response) => { + setLoading(false) + if (response.success && response.packs) { + setPacks(response.packs) + } else { + setError(response.error || 'Failed to search packs') + } + }) + }, [socket, searchQuery, fetchPacks]) + + useEffect(() => { + if (isOpen) { + fetchPacks() + fetchFeatured() + } + }, [isOpen, fetchPacks, fetchFeatured]) + + // Debounced search + useEffect(() => { + const timer = setTimeout(() => { + if (searchQuery) { + searchPacks() + } else { + fetchPacks() + } + }, 300) + + return () => clearTimeout(timer) + }, [searchQuery, searchPacks, fetchPacks]) + + // Filter and sort packs + const filteredPacks = packs + .filter(pack => !pack.isBuiltIn) // Exclude built-in packs + .filter(pack => selectedTheme === 'all' || pack.theme === selectedTheme) + .sort((a, b) => { + switch (sortBy) { + case 'rating': + return b.rating - a.rating + case 'downloads': + return b.downloads - a.downloads + case 'newest': + return 0 // Would need createdAt, default order is fine + case 'name': + return a.name.localeCompare(b.name) + default: + return 0 + } + }) + + const handleSelect = (packId: string) => { + onSelectPack(packId) + onClose() + } + + if (!isOpen) return null + + return ( + + e.target === e.currentTarget && onClose()} + > + + {/* Header */} +
+
+

Browse Card Packs

+ +
+ + {/* Search bar */} +
+ + 🔍 + + setSearchQuery(e.target.value)} + placeholder="Search by name, author, or theme..." + className="w-full pl-10 pr-4 py-3 bg-gray-800 rounded-lg text-white placeholder-gray-500 focus:ring-2 focus:ring-purple-500 outline-none" + /> +
+ + {/* Filters */} +
+ {/* Theme filter */} +
+ {THEMES.map(theme => ( + + ))} +
+ + {/* Sort dropdown */} +
+ +
+
+
+ + {/* Content */} +
+ {loading ? ( +
+ Loading packs... +
+ ) : error ? ( +
+ {error} +
+ ) : ( + <> + {/* Featured Section */} + {!searchQuery && featuredPacks.length > 0 && selectedTheme === 'all' && ( +
+

+ Featured Packs +

+
+ {featuredPacks.slice(0, 4).map(pack => ( + handleSelect(pack.id)} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + className={`p-4 rounded-xl text-left transition-all ${ + currentPackId === pack.id + ? 'bg-purple-600 ring-2 ring-purple-400' + : 'bg-gradient-to-br from-purple-900/50 to-blue-900/50 hover:from-purple-800/50 hover:to-blue-800/50' + }`} + > +
+

{pack.name}

+ +
+

{pack.description}

+
+ By {pack.author} + {pack.downloads} downloads +
+
+ ))} +
+
+ )} + + {/* All Packs */} +
+

+ {searchQuery ? `Search Results (${filteredPacks.length})` : `All Packs (${filteredPacks.length})`} +

+ + {filteredPacks.length === 0 ? ( +
+
📦
+

No packs found

+ {searchQuery && ( +

Try a different search term

+ )} +
+ ) : ( +
+ {filteredPacks.map(pack => ( + handleSelect(pack.id)} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + className={`p-4 rounded-xl text-left transition-all ${ + currentPackId === pack.id + ? 'bg-purple-600 ring-2 ring-purple-400' + : 'bg-gray-800 hover:bg-gray-700' + }`} + > +
+
+

+ {pack.name} + {pack.isMature && ( + + 18+ + + )} +

+
+
+ +

{pack.description}

+ +
+ + + {pack.cardCounts.characters}C / {pack.cardCounts.settings}S / {pack.cardCounts.circumstances}X + +
+ +
+ By {pack.author} + {pack.downloads} downloads +
+
+ ))} +
+ )} +
+ + )} +
+
+
+
+ ) +} diff --git a/components/CardPackEditor.tsx b/components/CardPackEditor.tsx new file mode 100644 index 00000000..7fe71560 --- /dev/null +++ b/components/CardPackEditor.tsx @@ -0,0 +1,516 @@ +'use client' + +import { useState, useCallback, useEffect } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { useSocket } from '@/contexts/SocketContext' +import type { CardPack, Card } from '@/lib/types' + +interface CardPackEditorProps { + isOpen: boolean + packId: string + onClose: () => void + onUpdated?: () => void +} + +const THEMES = [ + { value: 'mixed', label: 'Mixed', emoji: '🎭' }, + { value: 'office', label: 'Office', emoji: '💼' }, + { value: 'scifi', label: 'Sci-Fi', emoji: '🚀' }, + { value: 'fantasy', label: 'Fantasy', emoji: '🧙' }, + { value: 'horror', label: 'Horror', emoji: '👻' }, + { value: 'romance', label: 'Romance', emoji: '💕' }, + { value: 'action', label: 'Action', emoji: '💥' }, + { value: 'comedy', label: 'Comedy', emoji: '😂' } +] + +export function CardPackEditor({ isOpen, packId, onClose, onUpdated }: CardPackEditorProps) { + const { socket } = useSocket() + const [step, setStep] = useState(1) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [isSubmitting, setIsSubmitting] = useState(false) + + // Pack metadata + const [packName, setPackName] = useState('') + const [packDescription, setPackDescription] = useState('') + const [packTheme, setPackTheme] = useState('mixed') + const [authorName, setAuthorName] = useState('') + const [isMature, setIsMature] = useState(false) + const [isPublic, setIsPublic] = useState(true) + + // Cards + const [characters, setCharacters] = useState([]) + const [settings, setSettings] = useState([]) + const [circumstances, setCircumstances] = useState([]) + + // Load pack data + useEffect(() => { + if (!isOpen || !socket || !packId) return + + setIsLoading(true) + socket.emit('get_card_pack', packId, (response) => { + setIsLoading(false) + if (response.success && response.pack) { + const pack = response.pack + setPackName(pack.name) + setPackDescription(pack.description) + setPackTheme(pack.theme) + setAuthorName(pack.author) + setIsMature(pack.isMature) + setIsPublic(pack.isPublic) + setCharacters(pack.characters) + setSettings(pack.settings) + setCircumstances(pack.circumstances) + } else { + setError(response.error || 'Failed to load pack') + } + }) + }, [isOpen, socket, packId]) + + const addCard = (type: 'characters' | 'settings' | 'circumstances') => { + const newCard: Card = { id: `new-${Date.now()}`, name: '', description: '' } + if (type === 'characters') setCharacters([...characters, newCard]) + else if (type === 'settings') setSettings([...settings, newCard]) + else setCircumstances([...circumstances, newCard]) + } + + const removeCard = (type: 'characters' | 'settings' | 'circumstances', index: number) => { + if (type === 'characters' && characters.length > 5) { + setCharacters(characters.filter((_, i) => i !== index)) + } else if (type === 'settings' && settings.length > 3) { + setSettings(settings.filter((_, i) => i !== index)) + } else if (type === 'circumstances' && circumstances.length > 3) { + setCircumstances(circumstances.filter((_, i) => i !== index)) + } + } + + const updateCard = ( + type: 'characters' | 'settings' | 'circumstances', + index: number, + field: 'name' | 'description', + value: string + ) => { + if (type === 'characters') { + const updated = [...characters] + updated[index] = { ...updated[index], [field]: value } + setCharacters(updated) + } else if (type === 'settings') { + const updated = [...settings] + updated[index] = { ...updated[index], [field]: value } + setSettings(updated) + } else { + const updated = [...circumstances] + updated[index] = { ...updated[index], [field]: value } + setCircumstances(updated) + } + } + + const validateStep1 = () => { + if (packName.trim().length < 3) { + setError('Pack name must be at least 3 characters') + return false + } + if (!authorName.trim()) { + setError('Please enter your name as the author') + return false + } + setError(null) + return true + } + + const validateStep2 = () => { + const validCharacters = characters.filter(c => c.name.trim()) + if (validCharacters.length < 5) { + setError('Please add at least 5 characters') + return false + } + setError(null) + return true + } + + const validateStep3 = () => { + const validSettings = settings.filter(s => s.name.trim()) + const validCircumstances = circumstances.filter(c => c.name.trim()) + if (validSettings.length < 3) { + setError('Please add at least 3 settings') + return false + } + if (validCircumstances.length < 3) { + setError('Please add at least 3 circumstances') + return false + } + setError(null) + return true + } + + const handleNext = () => { + if (step === 1 && validateStep1()) setStep(2) + else if (step === 2 && validateStep2()) setStep(3) + else if (step === 3 && validateStep3()) handleSubmit() + } + + const handleSubmit = useCallback(() => { + if (!socket) return + + setIsSubmitting(true) + setError(null) + + const updates = { + name: packName.trim(), + description: packDescription.trim(), + author: authorName.trim(), + theme: packTheme, + isMature, + isPublic, + characters: characters.filter(c => c.name.trim()).map(c => ({ + id: c.id, + name: c.name.trim(), + description: c.description?.trim() || '' + })), + settings: settings.filter(s => s.name.trim()).map(s => ({ + id: s.id, + name: s.name.trim(), + description: s.description?.trim() || '' + })), + circumstances: circumstances.filter(c => c.name.trim()).map(c => ({ + id: c.id, + name: c.name.trim(), + description: c.description?.trim() || '' + })) + } + + socket.emit('update_card_pack', packId, updates, (response) => { + setIsSubmitting(false) + if (response.success) { + onUpdated?.() + onClose() + } else { + setError(response.error || 'Failed to update pack') + } + }) + }, [socket, packId, packName, packDescription, authorName, packTheme, isMature, isPublic, characters, settings, circumstances, onUpdated, onClose]) + + if (!isOpen) return null + + return ( + + e.target === e.currentTarget && onClose()} + > + + {/* Header */} +
+
+

Edit Card Pack

+ +
+ {/* Progress indicator */} +
+ {[1, 2, 3].map(s => ( +
+ ))} +
+
+ Details + Characters + Settings & Circumstances +
+
+ + {/* Content */} +
+ {isLoading ? ( +
+ Loading pack data... +
+ ) : ( + <> + {error && ( + + {error} + + )} + + {/* Step 1: Pack Details */} + {step === 1 && ( + +
+ + setPackName(e.target.value)} + placeholder="e.g., Superhero Showdown" + maxLength={50} + className="w-full p-3 bg-gray-800 rounded-lg text-white placeholder-gray-500 focus:ring-2 focus:ring-purple-500 outline-none" + /> +
+ +
+ +