diff --git a/app/AppContext.tsx b/app/AppContext.tsx new file mode 100644 index 0000000..664186e --- /dev/null +++ b/app/AppContext.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { createContext, useContext, useEffect, useState } from 'react'; +import useSocket from '@/hooks/useSocket'; + +import { GAME_START, LOCAL_DEFAULTS } from '@/constants'; +import type { Dispatch, ReactNode, SetStateAction } from 'react'; +import type { GameUpdate, LocalSettings, Settings, Tilt } from '@/types'; + +const AppContext = createContext(undefined); + +export interface AppContext { + gameData: GameUpdate; + isDM: boolean; + noGame: boolean; + selectCardIndex: number; + settings: Settings; + tilt: Tilt[]; + emitFlip: (cardIndex: number) => void; + emitSettings: (gameData: GameUpdate) => void; + emitRedraw: (cardIndex: number) => void; + emitSelect: (cardID: string) => void; + setGameID: (gameID: string) => void; + setLocalSettings: Dispatch>; + setSelectCardIndex: (cardIndex: number) => void; + setTilt: (tilt: Tilt[]) => void; +} + +export function AppProvider({ children }: { children: ReactNode }) { + const [gameData, setGameData] = useState({ ...GAME_START }); + const [localSettings, setLocalSettings] = useState(() => ({ ...LOCAL_DEFAULTS })); + const [gameID, setGameID] = useState(''); + const [noGame, setNoGame] = useState(false); + const [selectCardIndex, setSelectCardIndex] = useState(-1); + const [tilt, setTilt] = useState([]); + + const { emitFlip, emitRedraw, emitSelect, emitSettings, emitTilt } = useSocket({ + gameID, + setGameData, + setNoGame, + }); + + useEffect(() => { + if (localSettings.remoteTilt) { + const cardIndex = tilt.findIndex((tilt) => !!tilt); + + if (tilt[cardIndex]) { + emitTilt(cardIndex, tilt[cardIndex]); + } else { + // cardIndex does not matter + // all tilts for this user will be cleared + emitTilt(0, { rotateX: 0, rotateY: 0 }); + } + } + }, [tilt, localSettings]); + + const handleSelect = (cardID: string) => { + setSelectCardIndex(-1); + + emitSelect(selectCardIndex, cardID); + }; + + const { dmID } = gameData; + const isDM = !!dmID; + + const appInterface = { + gameData, + isDM, + noGame, + selectCardIndex, + settings: { ...gameData.settings, ...localSettings }, + tilt, + emitFlip, + emitSettings, + emitRedraw, + emitSelect: handleSelect, + setGameID, + setLocalSettings, + setSelectCardIndex, + setTilt, + }; + + return {children}; +} + +export function useAppContext(): AppContext { + const context = useContext(AppContext); + if (!context) throw new Error('useAppContext must be used within AppProvider'); + return context; +} diff --git a/app/[gameID]/page.tsx b/app/[gameID]/page.tsx index 32152b0..d873fda 100644 --- a/app/[gameID]/page.tsx +++ b/app/[gameID]/page.tsx @@ -1,105 +1,35 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useParams } from 'next/navigation'; -import useSocket from '@/hooks/useSocket'; -import { Eye } from 'lucide-react'; -import Card from '@/components/Card'; -import CopyButton from '@/components/CopyButton'; +import { useAppContext } from '@/app/AppContext'; +import CardSelect from '@/components/CardSelect'; import Notes from '@/components/Notes'; import NotFound from '@/components/NotFound'; -import Settings from '@/components/Settings'; -import CardSelect from '@/components/CardSelect'; - -import { cardMap, layout } from '@/constants/tarokka'; - -import type { Deck, GameUpdate } from '@/types'; +import Settings from '@/components/Settings/index'; +import { SpectatorLink } from '@/components/SpectatorLink'; +import TarokkaGrid from '@/components/TarokkaGrid'; export default function GamePage() { - const { gameID: gameIDParam } = useParams(); - - const [gameID, setGameID] = useState(''); - const [noGame, setNoGame] = useState(false); - const [selectCard, setSelectCard] = useState(-1); - const [gameData, setGameData] = useState({ - dmID: '', - spectatorID: '', - cards: [], - settings: { - positionBack: false, - positionFront: false, - prophecy: false, - notes: false, - cardStyle: 'color', - }, - }); - - const { dmID, cards, settings } = gameData; - const isDM = !!dmID; - const selectDeck: Deck | null = selectCard >= 0 ? cards[selectCard].deck : null; - - const socket = useSocket({ gameID, setGameData, setNoGame }); + const { noGame, setGameID } = useAppContext(); + const { gameID } = useParams(); useEffect(() => { - if (gameIDParam) { - setGameID(Array.isArray(gameIDParam) ? gameIDParam[0] : gameIDParam); + if (gameID) { + setGameID(Array.isArray(gameID) ? gameID[0] : gameID); } - }, [gameIDParam]); - - const select = (cardIndex: number, cardID: string) => { - setSelectCard(-1); - - socket.select(cardIndex, cardID); - }; - - // map our five Tarokka cards to their proper locations in a 3x3 grid - // common deck cards: left, top, and right - // high deck cards: bottom and center - const arrangeCards = (_cell: unknown, index: number) => cards[cardMap[index]]; + }, [gameID]); return noGame ? ( - ) : cards ? ( + ) : (
- {isDM && ( - - )} - - {isDM && } -
- {Array.from({ length: 9 }) - .map(arrangeCards) - .map((card, index) => ( -
- {card && ( - socket.flipCard(cardMap[index])} - redrawAction={() => socket.redraw(cardMap[index])} - selectAction={() => setSelectCard(cardMap[index])} - /> - )} -
- ))} -
- flipped)} /> - setSelectCard(-1)} - selectAction={(cardID) => select(selectCard, cardID)} - /> + + + + +
- ) : null; + ); } diff --git a/app/layout.tsx b/app/layout.tsx index 7dfc1d0..7d38c1d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next'; import { Pirata_One, Eagle_Lake, Cinzel_Decorative } from 'next/font/google'; +import { AppProvider } from '@/app/AppContext'; import './globals.css'; const pirataOne = Pirata_One({ @@ -40,7 +41,9 @@ export default function RootLayout({ lang="en" className={`${pirataOne.variable} ${eagleLake.variable} ${cinzel.variable} antialiased`} > - {children} + + {children} + ); } diff --git a/components/Card.tsx b/components/Card.tsx index cbbf34d..b27ba9b 100644 --- a/components/Card.tsx +++ b/components/Card.tsx @@ -1,48 +1,43 @@ 'use client'; import { useState } from 'react'; +import { useAppContext } from '@/app/AppContext'; import TiltCard from '@/components/TiltCard'; import ToolTip from '@/components/ToolTip'; import StackTheDeck from '@/components/StackTheDeck'; -import tarokkaCards from '@/constants/tarokkaCards'; import getCardInfo from '@/tools/getCardInfo'; import getURL from '@/tools/getURL'; -import { Layout, Settings, TarokkaGameCard } from '@/types'; +import tarokkaCards from '@/constants/tarokkaCards'; +import { layout } from '@/constants/tarokka'; + +import { TarokkaGameCard } from '@/types'; const cardBack = tarokkaCards.find((card) => card.back)!; type CardProps = { - dm: boolean; card: TarokkaGameCard; - position: Layout; - settings: Settings; - flipAction: () => void; - redrawAction: () => void; - selectAction: () => void; + cardIndex: number; }; -export default function Card({ - dm, - card, - position, - settings, - flipAction, - redrawAction, - selectAction, -}: CardProps) { +export default function Card({ card, cardIndex }: CardProps) { const [tooltip, setTooltip] = useState(null); + const { emitFlip, gameData, emitRedraw, setSelectCardIndex } = useAppContext(); + + const { dmID, settings } = gameData; + const isDM = !!dmID; const { aria, flipped } = card; + const position = layout[cardIndex]; const handleClick = () => { - if (dm) { - flipAction(); + if (isDM) { + emitFlip(cardIndex); } }; const getTooltip = () => { - const text = getCardInfo(card, position, dm, settings); + const text = getCardInfo(card, position, isDM, settings); return text.length ? ( <> @@ -59,14 +54,15 @@ export default function Card({ return (
- {dm && ( + {isDM && ( <> {aria} Card Back - {dm && !flipped && ( + {isDM && !flipped && ( selectAction()} + onRedraw={() => emitRedraw(cardIndex)} + onSelect={() => setSelectCardIndex(cardIndex)} onHover={setTooltip} /> )} diff --git a/components/CardSelect.tsx b/components/CardSelect.tsx index 401aff9..482ec0c 100644 --- a/components/CardSelect.tsx +++ b/components/CardSelect.tsx @@ -1,41 +1,36 @@ 'use client'; import { CircleX } from 'lucide-react'; +import { useAppContext } from '@/app/AppContext'; import TarokkaDeck from '@/lib/TarokkaDeck'; import getURL from '@/tools/getURL'; -import { Deck, Settings, TarokkaGameCard } from '@/types'; +import { Deck } from '@/types'; const tarokkaDeck = new TarokkaDeck(); type CardSelectProps = { - closeAction: () => void; - selectAction: (cardID: string) => void; - hand: TarokkaGameCard[]; - settings: Settings; - show: Deck | null; className?: string; }; -export default function CardSelect({ - closeAction, - selectAction, - hand, - settings, - show, - className = '', -}: CardSelectProps) { +export default function CardSelect({ className = '' }: CardSelectProps) { + const { gameData, emitSelect, selectCardIndex, setSelectCardIndex } = useAppContext(); + const { cards: hand, settings } = gameData; + const handIDs = hand.map(({ id }) => id); + const selectDeck: Deck | null = selectCardIndex >= 0 ? hand[selectCardIndex].deck : null; + + const close = () => setSelectCardIndex(-1); const handleClose = (event: React.MouseEvent) => { if (event.target === event.currentTarget) { - closeAction(); + close(); } }; - if (!show) return null; + if (!selectDeck) return null; - const cards = show === 'high' ? tarokkaDeck.getHigh() : tarokkaDeck.getLow(); + const cards = selectDeck === 'high' ? tarokkaDeck.getHigh() : tarokkaDeck.getLow(); return (
@@ -58,7 +53,7 @@ export default function CardSelect({
selectAction(card.id)} + onClick={() => emitSelect(card.id)} > flipped); const [open, setOpen] = useState(false); diff --git a/components/Settings.tsx b/components/Settings.tsx deleted file mode 100644 index ce5a83d..0000000 --- a/components/Settings.tsx +++ /dev/null @@ -1,133 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Settings as Gear } from 'lucide-react'; -import { Cinzel_Decorative } from 'next/font/google'; - -import BuyMeACoffee from '@/components/BuyMeACoffee'; -import CopyButton from '@/components/CopyButton'; -import GitHubButton from '@/components/GitHubButton'; -import Scrim from '@/components/Scrim'; -import Switch from '@/components/Switch'; -import { CardStyle, GameUpdate } from '@/types'; - -const cinzel = Cinzel_Decorative({ - variable: '--font-cinzel', - subsets: ['latin'], - weight: '400', -}); - -type SettingsProps = { - gameData: GameUpdate; - changeAction: (updatedSettings: GameUpdate) => void; -}; - -const cardStyleOptions: CardStyle[] = ['standard', 'color', 'grayscale']; - -export default function Settings({ gameData, changeAction }: SettingsProps) { - const [open, setOpen] = useState(false); - - const togglePermission = (key: string) => { - changeAction({ - ...gameData, - settings: { - ...gameData.settings, - [key]: !gameData.settings[key], - }, - }); - }; - - const tuneRadio = (cardStyle: CardStyle) => { - changeAction({ - ...gameData, - settings: { - ...gameData.settings, - cardStyle, - }, - }); - }; - - const Links = () => ( - <> - - - - ); - - const Permissions = () => ( - <> - {Object.entries(gameData.settings) - .filter(([_key, value]) => typeof value === 'boolean') - .map(([key, value]) => ( - togglePermission(key)} /> - ))} - - ); - - const CardStyle = () => ( -
-
Card style:
-
- {cardStyleOptions.map((option, index) => ( - - ))} -
-
- ); - - return ( -
- setOpen((prev) => !prev)} - className={`transition-all duration-250 ${open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`} - > -
- - - - - - - -
-
- -
- ); -} diff --git a/components/Settings/CardStyle.tsx b/components/Settings/CardStyle.tsx new file mode 100644 index 0000000..e0f3d7b --- /dev/null +++ b/components/Settings/CardStyle.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useAppContext } from '@/app/AppContext'; +import type { CardStyle } from '@/types'; + +const cardStyleOptions: CardStyle[] = ['standard', 'color', 'grayscale']; + +export default function CardStyle({ className }: { className?: string }) { + const { gameData, isDM, settings, emitSettings } = useAppContext(); + + const tuneRadio = (cardStyle: CardStyle) => { + emitSettings({ + ...gameData, + settings: { + ...gameData.settings, + cardStyle, + }, + }); + }; + + return isDM ? ( +
+
Card style:
+
+ {cardStyleOptions.map((option, index) => ( + + ))} +
+
+ ) : null; +} diff --git a/components/Settings/ExternalLinks.tsx b/components/Settings/ExternalLinks.tsx new file mode 100644 index 0000000..ea81f63 --- /dev/null +++ b/components/Settings/ExternalLinks.tsx @@ -0,0 +1,13 @@ +'use client'; + +import BuyMeACoffee from '@/components/BuyMeACoffee'; +import GitHubButton from '@/components/GitHubButton'; + +export default function CardStyle({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/components/Settings/GameLinks.tsx b/components/Settings/GameLinks.tsx new file mode 100644 index 0000000..207690a --- /dev/null +++ b/components/Settings/GameLinks.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { useAppContext } from '@/app/AppContext'; +import CopyButton from '@/components/CopyButton'; + +export default function Links({ className }: { className?: string }) { + const { gameData, isDM } = useAppContext(); + + return ( +
+ {isDM && ( + + )} + +
+ ); +} diff --git a/components/Settings/Permissions.tsx b/components/Settings/Permissions.tsx new file mode 100644 index 0000000..a95a1a5 --- /dev/null +++ b/components/Settings/Permissions.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useAppContext } from '@/app/AppContext'; +import Switch from '@/components/Switch'; +import { LOCAL_SETTINGS, SPECTATOR_SETTINGS } from '@/constants'; + +export default function Permissions() { + const { gameData, isDM, settings, emitSettings, setLocalSettings } = useAppContext(); + + const togglePermission = (key: string) => { + if (LOCAL_SETTINGS.includes(key)) { + setLocalSettings((prev) => ({ ...prev, [key]: !prev[key] })); + } else if (isDM) { + emitSettings({ + ...gameData, + settings: { + ...gameData.settings, + [key]: !gameData.settings[key], + }, + }); + } + }; + + return ( + <> + {Object.entries(settings) + .filter(([_key, value]) => typeof value === 'boolean') + .filter(([key]) => isDM || SPECTATOR_SETTINGS.includes(key)) + .map(([key, value]) => ( + togglePermission(key)} /> + ))} + + ); +} diff --git a/components/Settings/index.tsx b/components/Settings/index.tsx new file mode 100644 index 0000000..bee8a35 --- /dev/null +++ b/components/Settings/index.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useState } from 'react'; +import { CircleX, Settings as Gear } from 'lucide-react'; +import { Cinzel_Decorative } from 'next/font/google'; + +import { useAppContext } from '@/app/AppContext'; +import Scrim from '@/components/Scrim'; + +import CardStyle from './CardStyle'; +import ExternalLinks from './ExternalLinks'; +import GameLinks from './GameLinks'; +import Permissions from './Permissions'; + +const cinzel = Cinzel_Decorative({ + variable: '--font-cinzel', + subsets: ['latin'], + weight: '400', +}); + +export default function Settings() { + const [open, setOpen] = useState(false); + const { isDM } = useAppContext(); + + return ( +
+ setOpen((prev) => !prev)} + className={`transition-all duration-250 ${open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`} + > +
+ + + + +
+ +
+ +
+ ); +} diff --git a/components/SpectatorLink.tsx b/components/SpectatorLink.tsx new file mode 100644 index 0000000..9b24b23 --- /dev/null +++ b/components/SpectatorLink.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { Eye } from 'lucide-react'; +import { useAppContext } from '@/app/AppContext'; +import CopyButton from '@/components/CopyButton'; + +export function SpectatorLink() { + const { gameData } = useAppContext(); + + return ( + + ); +} diff --git a/components/Switch.tsx b/components/Switch.tsx index 0931631..163fa02 100644 --- a/components/Switch.tsx +++ b/components/Switch.tsx @@ -1,25 +1,44 @@ +import type { ChangeEventHandler } from 'react'; + export interface SwitchProps { label: string; value: boolean; - toggleAction: (event: React.ChangeEvent) => void; + toggleAction: ChangeEventHandler; + className?: string; } -export default function Switch({ label, value, toggleAction }: SwitchProps) { +const nonInitialCaps = /(?!^)([A-Z])/g; + +export default function Switch({ label, value, toggleAction, className }: SwitchProps) { return ( -