diff --git a/index.html b/index.html index 77baf3c..d2f55d2 100644 --- a/index.html +++ b/index.html @@ -18,7 +18,7 @@
- + \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 369ba33..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import Game from './Game'; -import Online, { Provider as OnlineProvider } from './Online'; - -import './Online/firebaseConfig'; - -export default function App() { - return ( - - - - - ); -} diff --git a/src/Game/Game.css b/src/Board.css similarity index 100% rename from src/Game/Game.css rename to src/Board.css diff --git a/src/Online/Chat/index.css b/src/Chat.css similarity index 100% rename from src/Online/Chat/index.css rename to src/Chat.css diff --git a/src/Online/Chat/index.tsx b/src/Chat.tsx similarity index 67% rename from src/Online/Chat/index.tsx rename to src/Chat.tsx index 2e8810f..7eded26 100644 --- a/src/Online/Chat/index.tsx +++ b/src/Chat.tsx @@ -1,20 +1,28 @@ -import { useContext, useState } from 'react'; +import { useState, useCallback } from 'react'; import firebase from 'firebase/compat/app'; import 'firebase/compat/database'; -import { AuthContext } from '../Contexts'; -import './index.css'; +import './Chat.css'; -export default function() { - const user = useContext(AuthContext); - const [chats, setChats] = useState([]); +export default function ({ chats, user }) { + // TODO: Implement Chat form const [selectedChat, setSelectedChat] = useState(null); const handleChatClick = (chat: firebase.database.DataSnapshot) => { setSelectedChat(chat); }; + const chat = useCallback((chatId: string, message: string) => { + if (chatId && user) { + firebase.database().ref(`chats/${chatId}`).push({ + message, + author: user.key, + time: new Date().toISOString() + }) + } + }, [user]); + return ( -
+

Chat List

    {chats.map((chat: firebase.database.DataSnapshot) => ( diff --git a/src/Game/Dice.css b/src/Dice.css similarity index 100% rename from src/Game/Dice.css rename to src/Dice.css diff --git a/src/Game/Dice.tsx b/src/Dice.tsx similarity index 100% rename from src/Game/Dice.tsx rename to src/Dice.tsx diff --git a/src/Online/Friends/index.css b/src/Friends.css similarity index 96% rename from src/Online/Friends/index.css rename to src/Friends.css index 72dd8c8..bd0a88d 100644 --- a/src/Online/Friends/index.css +++ b/src/Friends.css @@ -1,14 +1,16 @@ #friends { - background: rgba(255,255,255,.4); - padding: 0 2em; + padding: 0 1em; + input { display: block; width: 100%; padding: 5px; } + #people ul { list-style: none; padding: 0; + li { display: flex; align-items: center; @@ -28,18 +30,22 @@ object-fit: cover; } } + h1 { vertical-align: text-top; display: flex; justify-content: space-between; + span { display: flex; overflow: hidden; + span { margin-right: .2em; } } } + [aria-haspopup="menu"] { float: right; } diff --git a/src/Online/Friends/index.tsx b/src/Friends.tsx similarity index 54% rename from src/Online/Friends/index.tsx rename to src/Friends.tsx index 74eb00a..ac85c3c 100644 --- a/src/Online/Friends/index.tsx +++ b/src/Friends.tsx @@ -1,40 +1,22 @@ // Import FirebaseAuth and firebase. -import { useState, useCallback, useRef, useContext, ReactNode, useEffect } from 'react'; +import { useState, useCallback, useRef, ReactNode, useEffect } from 'react'; import type { ChangeEventHandler } from 'react'; - +import { formatDistance } from 'date-fns'; import firebase from 'firebase/compat/app'; import 'firebase/compat/auth'; import 'firebase/compat/database'; -import { AuthContext, UserData } from '../Contexts'; -import './index.css' -import { MultiplayerContext, Match, ModalContext } from '../Contexts'; -import { Avatar } from '../Profile'; -import { formatDistance } from 'date-fns'; +import { UserData, Match } from './Types'; +import { Avatar } from './Profile'; +import './Friends.css' +import ToggleFullscreen from './ToggleFullscreen'; +type Users = { [key: string]: UserData } -const toggleFullscreen = () => - document.fullscreenElement - ? document.exitFullscreen() - : document.documentElement.requestFullscreen() -type Users = {[key: string]: UserData} - -export default function Friends() { +export default function Friends({ authUser, toggle, load, reset }) { const searchRef = useRef(null); - const authUser = useContext(AuthContext); // Local signed-in state. const [users, setUsers] = useState({}); const [isExpanded, setIsExpanded] = useState(false); - const { load, reset } = useContext(MultiplayerContext); - const { toggle } = useContext(ModalContext); const [matches, setMatches] = useState([]); const [searchResults, setSearchResults] = useState([]); - const [fullscreen, setFullscreen] = useState(!!document.fullscreenElement); - - // Synchronize Fullscreen Icon - useEffect(() => { - const fullscreenchange = () => setFullscreen(!!document.fullscreenElement); - document.addEventListener('fullscreenchange', fullscreenchange); - return () => document.removeEventListener('fullscreenchange', fullscreenchange); - }, []) - // Synchronize Matches useEffect(() => { @@ -58,8 +40,8 @@ export default function Friends() { queryMatches.off('value', subscriber); } }, [authUser]); - - const onSearch: ChangeEventHandler = useCallback(async() => { + + const onSearch: ChangeEventHandler = useCallback(async () => { if (searchRef.current?.value) { const search = searchRef.current.value const searchSnapshot = await firebase.database().ref('users').orderByChild('name').startAt(search).get(); @@ -81,17 +63,17 @@ export default function Friends() { const NOW = new Date() const row = (user: UserData, match?: Match) =>
  • load(user.uid)}> - -
    -

    {user.name}

    - - {match?.lastMessage} -
    -
  • + +
    +

    {user.name}

    + + {match?.lastMessage} +
    + - const searchReject = (user: UserData) => - searchRef.current?.value - && !(new RegExp(searchRef.current?.value, 'i')).test(user.name) + const searchReject = (user: UserData) => + searchRef.current?.value + && !(new RegExp(searchRef.current?.value, 'i')).test(user.name) && !(new RegExp(searchRef.current?.value, 'i')).test(user.uid) matches?.forEach(match => { @@ -121,60 +103,59 @@ export default function Friends() { } } - return
    - - -
  • - - person_add_alt_1 - Invite Friend - -
  • - {document.fullscreenEnabled ? + return
    +
    + + +
  • + + person_add_alt_1 + Invite Friend + +
  • + {document.fullscreenEnabled ? +
  • + +
  • + : null} +
  • + toggle('profile')}> + manage_accounts + Edit Profile + +
  • +
  • + + restart_alt + Reset Match + +
  • - - {fullscreen ? 'fullscreen_exit' : 'fullscreen'} - Fullscreen + firebase.auth().signOut()}> + logout + Logout
  • - : null} -
  • - toggle('profile')}> - manage_accounts - Edit Profile - -
  • -
  • - - restart_alt - Reset Match - -
  • -
  • - firebase.auth().signOut()}> - logout - Logout - -
  • -
    -

    - - {authUser.val().name}'s - Matches - -

    +
    +

    + + {authUser.val().name}'s + Matches + +

    +
      {renderFriends}
    -
    + } diff --git a/src/Game/index.tsx b/src/Game/index.tsx deleted file mode 100644 index 7f034be..0000000 --- a/src/Game/index.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import './Game.css'; -import Dice from './Dice'; -import Point from './Point'; -import Piece from './Piece'; -import Toolbar from '../Toolbar' -import { useCallback, useContext, useState, type DragEventHandler } from 'react'; -import { MatchContext } from '../Online/Contexts'; -import useGameState from './useGameState'; - -export default function Game() { - const [selected, setSelected] = useState(null); - const match = useContext(MatchContext); - const { state: game, rollDice, move } = useGameState(match?.game); - - const onDragOver: DragEventHandler = useCallback((event) => { event.preventDefault(); }, []) - const onDrop: DragEventHandler = useCallback((event) => { - event.preventDefault(); - let from = parseInt(event.dataTransfer?.getData("text")!) - return move(from, -1,) - }, [move]) - - const onSelect = useCallback((position: number | null) => { - if (position === null || selected === position) { - setSelected(null); - } else if (selected === null) { - setSelected(position); - } else { - move(selected, position); - setSelected(null); - } - }, [selected, move]) - - return
    - - - -
    - {Array.from({ length: game.prison.white }, (_, index) => - - )} -
    -
    - {Array.from({ length: game.prison.black }, (_, index) => - - )} -
    -
    - {Array.from({ length: game.home.black }, (_, index) => - - )} -
    -
    - {Array.from({ length: game.home.white }, (_, index) => - - )} -
    - {game.board.map((pieces: number, index: number) => - - )} -
    ; -} diff --git a/src/Game/useGameState.ts b/src/Game/useGameState.ts deleted file mode 100644 index 1160bde..0000000 --- a/src/Game/useGameState.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { useReducer, useContext, useEffect } from "react"; -import { MultiplayerContext, type GameType } from "../Online/Contexts"; -import firebase from "firebase/compat/app"; - -// White = Positive, Black = Negative -const DEFAULT_BOARD = [ - 5, 0, 0, 0, -3, 0, -5, 0, 0, 0, 0, 2, -5, 0, 0, 0, 3, 0, 5, 0, 0, 0, 0, -2, -]; - -function rollDie() { - return Math.floor(Math.random() * 6) + 1; -} - -function vibrate() { - navigator.vibrate?.([50, 100, 60, 60, 90, 40, 110, 20, 150]); -} - -export const newGame = (oldGame?: GameType) => ({ - status: "", - board: [...(oldGame?.board || DEFAULT_BOARD)], - dice: oldGame?.dice || [6, 6], - prison: oldGame?.prison || { - black: 0, - white: 0, - }, - home: oldGame?.home || { - black: 0, - white: 0, - }, -} as GameType); - -interface MoveAction { - type: typeof Actions.MOVE; - data: { - from: number | "white" | "black"; - to: number; - }; -} - -interface SetGameAction { - type: typeof Actions.SET_GAME; - data: GameType; -} - -interface RollAction { - type: typeof Actions.ROLL; - data: { - dice: number[]; - }; -} - -type Action = MoveAction | SetGameAction | RollAction; - -export enum Actions { - LOAD = "LOAD", - ROLL = "ROLL", - MOVE = "MOVE", - SET_GAME = "SET_GAME", -} - -const firstGame = newGame(); - -function calculate(state: GameType, from: number | "white" | "black", to: number) { - if (from === to) return { state }; // no move - const nextGame: GameType = newGame(state); - let moveLabel: string; // @see https://en.wikipedia.org/wiki/Backgammon_notation - if (from === "white") { - // white re-enter - if (nextGame.board[to] === -1) { - // hit - moveLabel = `bar/${to}*`; - nextGame.prison.black++; - nextGame.prison.white--; - nextGame.board[to] = 1; - } else if (nextGame.board[to] >= -1) { - // move - moveLabel = `bar/${to}`; - nextGame.prison.white--; - nextGame.board[to]++; - } else { - // blocked - return { state }; - } - } else if (from === "black") { - // black re-enter - if (nextGame.board[to] === 1) { - // hit - moveLabel = `bar/${to}*`; - nextGame.prison.white++; - nextGame.prison.black--; - nextGame.board[to] = -1; - } else if (nextGame.board[to] <= 1) { - // move - moveLabel = `bar/${to}`; - nextGame.prison.black--; - nextGame.board[to]--; - } else { - // blocked - return { state }; - } - } else { - const offense = nextGame.board[from]; - const defense = nextGame.board[to]; - - if (defense === undefined) { - // bear off - moveLabel = `${from}/off`; - if (offense > 0) { - nextGame.home.white++; - } else { - nextGame.home.black++; - } - } else if (!defense || Math.sign(defense) === Math.sign(offense)) { - // move - moveLabel = `${from}/${to}`; - nextGame.board[to] += Math.sign(offense); - } else if (Math.abs(defense) === 1) { - // hit - moveLabel = `${from}/${to}*`; - nextGame.board[to] = -Math.sign(defense); - if (offense > 0) nextGame.prison.black++; - else nextGame.prison.white++; - } else { - // blocked - return { state }; - } - - // remove from previous position - nextGame.board[from] -= Math.sign(nextGame.board[from]); - } - return { state: nextGame, moveLabel }; -} - -function reducer(state: GameType, action: Action): GameType { - switch (action.type) { - case Actions.SET_GAME: - return { ...state, ...action.data }; - case Actions.ROLL: - return { ...state, dice: action.data.dice }; - default: - return state; - } -} - -export default function useGameState(gameId?: string) { - const [state, dispatch] = useReducer(reducer, firstGame); - const { move: sendMove } = useContext(MultiplayerContext); - - useEffect(() => { - if (gameId) { - const gameRef = firebase.database().ref(`games/${gameId}`) - const onValue = (snapshot: firebase.database.DataSnapshot) => { - const value = snapshot.val(); - if (value) { - dispatch({ type: Actions.SET_GAME, data: value }); - } else { - dispatch({ type: Actions.SET_GAME, data: firstGame }); - gameRef.set(firstGame); - } - }; - gameRef.on("value", onValue); - return () => { - gameRef.off("value", onValue); - }; - } - }, [gameId]); - - const rollDice = () => { - const newDice = [rollDie(), rollDie()]; - dispatch({ type: Actions.ROLL, data: { dice: newDice } }); - const audio = new Audio('./shake-and-roll-dice-soundbible.mp3'); - audio.play(); - vibrate(); - if (gameId) - firebase.database().ref(`games/${gameId}/dice`).set(newDice); - }; - - const move = (from: number | "white" | "black", to: number) => { - const { state: nextState, moveLabel } = calculate(state, from, to); - if (!moveLabel) return; - dispatch({ type: Actions.SET_GAME, data: nextState }); - // dispatch({ type: Actions.MOVE, data: { from, to } }); - sendMove(nextState, `${nextState.dice.join("-")}: ${moveLabel}`); - }; - - const reset = () => { - if (confirm('Are you sure you want to reset the match?')) { - console.log('Resetting', gameId); - let data = newGame() - if (gameId) - firebase.database().ref(`games/${gameId}`).set(data); - dispatch({ type: Actions.SET_GAME, data }); - } - } - - return { - state, - rollDice, - move, - reset, - }; -} diff --git a/src/Online/StyledFirebaseAuth.tsx b/src/Login.tsx similarity index 61% rename from src/Online/StyledFirebaseAuth.tsx rename to src/Login.tsx index aeb7685..be1d7b7 100644 --- a/src/Online/StyledFirebaseAuth.tsx +++ b/src/Login.tsx @@ -1,8 +1,29 @@ +// TODO: Cleanup this file // https://github.com/firebase/firebaseui-web-react/pull/173#issuecomment-1151532176 import { useEffect, useRef, useState } from 'react'; import { onAuthStateChanged } from 'firebase/auth'; -import * as firebaseui from 'firebaseui'; import 'firebaseui/dist/firebaseui.css'; +import * as firebaseui from 'firebaseui'; +import firebase from 'firebase/compat/app'; +import 'firebase/compat/auth'; + +// Configure FirebaseUI. +const uiConfig = { + // Popup signin flow rather than redirect flow. + signInFlow: 'popup', + // We will display Google and Facebook as auth providers. + signInOptions: [ + // firebase.auth.PhoneAuthProvider.PROVIDER_ID, // Requires Billing + firebase.auth.EmailAuthProvider.PROVIDER_ID, + firebase.auth.GoogleAuthProvider.PROVIDER_ID, + // firebase.auth.FacebookAuthProvider.PROVIDER_ID, // Requires Facebook App ID + firebaseui.auth.AnonymousAuthProvider.PROVIDER_ID + ], + callbacks: { + // Avoid redirects after sign-in. + signInSuccessWithAuthResult: () => false, + }, +}; interface Props { // The Firebase UI Web UI Config object. @@ -18,27 +39,23 @@ interface Props { } -export default function StyledFirebaseAuth({ uiConfig, firebaseAuth, className, uiCallback }: Props) { +export default function Login() { const [userSignedIn, setUserSignedIn] = useState(false); const elementRef = useRef(null); useEffect(() => { // Get or Create a firebaseUI instance. - const firebaseUiWidget = firebaseui.auth.AuthUI.getInstance() || new firebaseui.auth.AuthUI(firebaseAuth); + const firebaseUiWidget = firebaseui.auth.AuthUI.getInstance() || new firebaseui.auth.AuthUI(firebase.auth()); if (uiConfig.signInFlow === 'popup') firebaseUiWidget.reset(); // We track the auth state to reset firebaseUi if the user signs out. - const unregisterAuthObserver = onAuthStateChanged(firebaseAuth, (user) => { + const unregisterAuthObserver = onAuthStateChanged(firebase.auth(), (user) => { if (!user && userSignedIn) firebaseUiWidget.reset(); setUserSignedIn(!!user); }); - // Trigger the callback if any was set. - if (uiCallback) - uiCallback(firebaseUiWidget); - // Render the firebaseUi Widget. // @ts-ignore firebaseUiWidget.start(elementRef.current, uiConfig); @@ -49,5 +66,10 @@ export default function StyledFirebaseAuth({ uiConfig, firebaseAuth, className, }; }, [firebaseui, uiConfig]); - return
    ; -}; \ No newline at end of file + return ( +
    +

    Play Online

    +
    +
    + ); +} diff --git a/src/Online/Login.tsx b/src/Online/Login.tsx deleted file mode 100644 index ebf27ed..0000000 --- a/src/Online/Login.tsx +++ /dev/null @@ -1,30 +0,0 @@ -// Import FirebaseAuth and firebase. -import * as firebaseui from 'firebaseui'; -import StyledFirebaseAuth from './StyledFirebaseAuth'; -import firebase from 'firebase/compat/app'; -import 'firebase/compat/auth'; - -// Configure FirebaseUI. -const uiConfig = { - // Popup signin flow rather than redirect flow. - signInFlow: 'popup', - // We will display Google and Facebook as auth providers. - signInOptions: [ - // firebase.auth.PhoneAuthProvider.PROVIDER_ID, // Requires Billing - firebase.auth.EmailAuthProvider.PROVIDER_ID, - firebase.auth.GoogleAuthProvider.PROVIDER_ID, - // firebase.auth.FacebookAuthProvider.PROVIDER_ID, // Requires Facebook App ID - firebaseui.auth.AnonymousAuthProvider.PROVIDER_ID - ], - callbacks: { - // Avoid redirects after sign-in. - signInSuccessWithAuthResult: () => false, - }, -}; - -export default function Login() { - return
    -

    Play Online

    - -
    -} diff --git a/src/Online/firebaseConfig.ts b/src/Online/firebaseConfig.ts deleted file mode 100644 index aad0688..0000000 --- a/src/Online/firebaseConfig.ts +++ /dev/null @@ -1,16 +0,0 @@ -// TODO: Upgrade to modular after firebaseui upgrades -import firebase from 'firebase/compat/app'; -// import { initializeApp } from 'firebase/app'; - -const firebaseConfig = { - apiKey: "AIzaSyDSTc5VVNNT32jRE4m8qr7hVbI8ahaIsRc", - authDomain: "peaceinthemiddleeast.firebaseapp.com", - databaseURL: "https://peaceinthemiddleeast-default-rtdb.firebaseio.com", - projectId: "peaceinthemiddleeast", - storageBucket: "peaceinthemiddleeast.appspot.com", - messagingSenderId: "529824094542", - appId: "1:529824094542:web:eadc5cf0dc140a2b0de61f", - measurementId: "G-NKGPNTLDF1" -}; - -export default firebase.initializeApp(firebaseConfig); diff --git a/src/Online/index.tsx b/src/Online/index.tsx deleted file mode 100644 index be60a94..0000000 --- a/src/Online/index.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import Friends from "./Friends"; -import Chat from "./Chat"; -import Profile from "./Profile"; -import Login from "./Login"; -import { useContext, useEffect, useState, PropsWithChildren, useCallback, useMemo } from "react"; -import { ModalContext, AuthContext, ChatContext, FriendContext, MatchContext, Match, GameType, MultiplayerContext, SnapshotOrNullType, UserData, ModalState, Move } from "./Contexts"; -import firebase from 'firebase/compat/app'; -import 'firebase/compat/database'; -import { newGame } from "../Game/useGameState"; - -/** - * The rendered component tree - */ -export default () => { - const { state } = useContext(ModalContext); - const authUserSnapshot = useContext(AuthContext); - if (state) { - if (!authUserSnapshot) return ; - switch (state) { - case 'chat': return ; - case 'profile': return ; - default: return ; - } - } - return null; -} - -/** - * Context Provider for the Online tree - */ -export function Provider({ children }: PropsWithChildren) { - const database = firebase.database(); - const [user, setUser] = useState(null); - const [state, setState] = useState(false); - const [lastState, setLastState] = useState('friends'); - const [match, setMatch] = useState(null); - const [chats, setChats] = useState(null); - const [friend, setFriend] = useState(null); - - const toggle = useCallback((newState: ModalState) => { - if (newState === true) { - setState(prevState => { - if (prevState) setLastState(prevState); - return lastState; - }); - } else if (newState === false) { - setState(prevState => { - if (prevState) setLastState(prevState); - return false; - }); - } else { - setState(prevState => { - if (prevState) setLastState(prevState); - return newState - }); - } - }, [lastState]); - - const chat = useCallback((message: string) => { - if (match && user) { - database.ref(`chats/${match.chat}`).push({ - message, - author: user.key, - time: new Date().toISOString() - }) - } - }, [match, user]); - - const move = useCallback((game: GameType, move: Move["move"]) => { - if (match?.game) { - const time = new Date().toISOString(); - const nextMove: Move = { - player: user?.val().uid, - game: match.game, - move, - time, - } - const update = { - sort: time, - }; - database.ref('moves').push(nextMove) - database.ref(`games/${match.game}`).update(game) - database.ref(`matches/${user?.key}/${friend?.key}`).update(update); - database.ref(`matches/${friend?.key}/${user?.key}`).update(update); - - } - }, [match, user, friend]); - - const load = useCallback(async (userId?: string) => { - console.log('Loading', userId); - - if (!user || !userId) { - setMatch(null); - setChats(null); - setFriend(null); - return; - } - - window.history.pushState(null, '', `${userId}`); - const userSnapshot = await database.ref(`users/${userId}`).get(); - if (!userSnapshot.exists()) { - console.error('User not found', userId); - return; - } - - setFriend(userSnapshot); - const matchSnapshot = await database.ref(`matches/${user.key}/${userId}`).get(); - if (!matchSnapshot.exists()) { - // Create new match - const gameRef = database.ref('games').push(); - const chatRef = database.ref('chats').push(); - // Point match to game - const data: Match = { - sort: new Date().toISOString(), - game: gameRef.key!, - chat: chatRef.key!, - }; - database.ref(`matches/${user.key}/${userId}`).set(data); - database.ref(`matches/${userId}/${user.key}`).set(data); - setMatch(data); - } else { - setMatch(await matchSnapshot.val()) - } - toggle(false); - }, [user]); - - const reset = useCallback(async () => { - if (match?.game) { - if (confirm('Are you sure you want to reset the match?')) { - console.log('Resetting', match.game); - database.ref(`games/${match.game}`).set(newGame()); - // TODO: update state? - } - } - }, [match]); - - // Autoload Match upon Login - useEffect(() => { - if (!user) return; - - const friendLocation = location.pathname.split('/').pop() - if (friendLocation && friendLocation !== 'PeaceInTheMiddleEast') load(friendLocation); - }, [load, user]); - - // onLogin/Logout - useEffect(() => { - const unregisterAuthObserver = firebase.auth().onAuthStateChanged(async authUser => { - if (authUser) { - const userRef = firebase.database().ref(`users/${authUser.uid}`) - let snapshot = await userRef.get() - if (!snapshot.exists()) { - // Upload initial user data - const data: UserData = { - uid: authUser.uid, - name: authUser.displayName || authUser.uid, - photoURL: authUser.photoURL, - language: navigator.language, - }; - console.log('Creating user', data); - userRef.set(data); - } - userRef.on('value', setUser); - } else { - setUser(null); - } - }); - return () => unregisterAuthObserver(); - }, []); - - - return ( - - ({ toggle, state }), [toggle, state])}> - ({ load, move, chat, reset }), [load, move, chat, reset])}> - - - - {children} - - - - - - - ); -} \ No newline at end of file diff --git a/src/Game/Piece.css b/src/Piece.css similarity index 100% rename from src/Game/Piece.css rename to src/Piece.css diff --git a/src/Game/Piece.tsx b/src/Piece.tsx similarity index 100% rename from src/Game/Piece.tsx rename to src/Piece.tsx diff --git a/src/Game/Point.tsx b/src/Point.tsx similarity index 70% rename from src/Game/Point.tsx rename to src/Point.tsx index 6f1abb0..cc5282c 100644 --- a/src/Game/Point.tsx +++ b/src/Point.tsx @@ -5,14 +5,15 @@ type PointProps = { pieces: number, move: (from: number | 'black' | 'white', to: number) => void, position: number, - selected: boolean, - onSelect: (position: number) => void + selected: number | null, + onSelect: (position: number | null) => void } export default function Point({ pieces, move, position, onSelect, selected }: PointProps) { const onDragOver: DragEventHandler = useCallback((event) => { event.preventDefault(); }, []) const onDrop: DragEventHandler = useCallback((event) => { event.preventDefault(); + // onSelect(null) let from = event.dataTransfer?.getData("text")! return move(from, position) }, [move]) @@ -20,10 +21,11 @@ export default function Point({ pieces, move, position, onSelect, selected }: Po const color = pieces > 0 ? 'white' : 'black'; const onClick = useCallback(() => { - onSelect(position) - }, [position, onSelect]) + if (pieces !== 0 || selected !== null) + onSelect(position) + }, [pieces, position, onSelect]) - return
    + return
    {Array.from({ length: Math.abs(pieces) }, (_, index) => )}
    } \ No newline at end of file diff --git a/src/Online/Profile/index.css b/src/Profile.css similarity index 100% rename from src/Online/Profile/index.css rename to src/Profile.css diff --git a/src/Online/Profile/index.tsx b/src/Profile.tsx similarity index 88% rename from src/Online/Profile/index.tsx rename to src/Profile.tsx index 371b0f7..707c23c 100644 --- a/src/Online/Profile/index.tsx +++ b/src/Profile.tsx @@ -3,9 +3,9 @@ import { useState, useCallback, useContext, ChangeEvent } from 'react'; import firebase from 'firebase/compat/app'; import 'firebase/compat/auth'; import 'firebase/compat/database'; -import { AuthContext, UserData } from '../Contexts'; -import { ModalContext } from '../Contexts'; -import './index.css' +import { AuthContext, UserData } from './Types'; +import { ModalContext } from './Types'; +import './Profile.css' export const LANGUAGES = ["af", "af-NA", "af-ZA", "agq", "agq-CM", "ak", "ak-GH", "am", "am-ET", "ar", "ar-001", "ar-AE", "ar-BH", "ar-DJ", "ar-DZ", @@ -116,33 +116,33 @@ export const Avatar = ({ user }: AvatarProps) => user ? {user.name} : - -export default function Profile() { - const { toggle } = useContext(ModalContext); - const authUserSnapshot = useContext(AuthContext); // Local signed-in state. - const [editing, setEditing] = useState(authUserSnapshot?.val() || { uid: '', name: '', language: '', photoURL: '' }); + +export default function Profile({ authUser, toggle }) { + const [editing, setEditing] = useState(authUser?.val() || { uid: '', name: '', language: '', photoURL: '' }); const save = useCallback(async (event: React.FormEvent) => { event.preventDefault(); if (!editing) return; - const userRef = firebase.database().ref(`users/${authUserSnapshot!.key}`); + const userRef = firebase.database().ref(`users/${authUser!.key}`); userRef.set(editing); toggle('friends') - }, [editing, authUserSnapshot]); + }, [editing, authUser]); const generateOnChange = (key: string) => (event: ChangeEvent) => { setEditing(editing => ({ ...editing, [key]: event.target.value })); }; - return
    + return
    -

    - toggle('friends')}> - - - Edit Profile - -

    +
    +

    + toggle('friends')}> + + + Edit Profile + +

    +
    -
    + } diff --git a/src/ToggleFullscreen.tsx b/src/ToggleFullscreen.tsx new file mode 100644 index 0000000..9d09c09 --- /dev/null +++ b/src/ToggleFullscreen.tsx @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; + +const toggleFullscreen = () => + document.fullscreenElement + ? document.exitFullscreen() + : document.documentElement.requestFullscreen() + +export default function ToggleFullscreen() { + const [fullscreen, setFullscreen] = useState(!!document.fullscreenElement); + + // Synchronize Fullscreen Icon + useEffect(() => { + const fullscreenchange = () => setFullscreen(!!document.fullscreenElement); + document.addEventListener('fullscreenchange', fullscreenchange); + return () => document.removeEventListener('fullscreenchange', fullscreenchange); + }, []) + + return + {fullscreen ? 'fullscreen_exit' : 'fullscreen'} + {fullscreen ? 'Exit Fullscreen' : 'Fullscreen'} + +} \ No newline at end of file diff --git a/src/Toolbar/index.css b/src/Toolbar.css similarity index 100% rename from src/Toolbar/index.css rename to src/Toolbar.css diff --git a/src/Toolbar/index.tsx b/src/Toolbar/index.tsx deleted file mode 100644 index d892891..0000000 --- a/src/Toolbar/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { useCallback, useContext } from 'react' -import './index.css' -import { FriendContext, ModalContext } from '../Online/Contexts' - -export default function Toolbar() { - const {state, toggle} = useContext(ModalContext) - const friend = useContext(FriendContext); - - const renderFriend = friend ?

    {friend.val().name}

    : null; - - const toggleFriends = useCallback(() => { toggle(!state) }, [state]) - - return
    - account_circle - {renderFriend} -
    -} \ No newline at end of file diff --git a/src/Online/Contexts.ts b/src/Types.ts similarity index 65% rename from src/Online/Contexts.ts rename to src/Types.ts index a7b3fcd..e69501b 100644 --- a/src/Online/Contexts.ts +++ b/src/Types.ts @@ -1,4 +1,3 @@ -import { createContext } from 'react'; import firebase from 'firebase/compat/app'; import 'firebase/compat/database'; @@ -64,18 +63,3 @@ export type ChatContextType = { send: (message: string) => void; state: SnapshotOrNullType; } - -export const AuthContext = createContext(null); -export const MultiplayerContext = createContext({ - load: (userId: UserData["uid"]) => { }, - move: (game: GameType, move: string) => { }, - chat: (message: string) => { }, - reset: () => { }, -}); -export const ChatContext = createContext(null); -export const MatchContext = createContext(null); -export const FriendContext = createContext(null); -export const ModalContext = createContext({ - toggle: (newState: ModalState) => { }, - state: false -}); \ No newline at end of file diff --git a/src/Utils.ts b/src/Utils.ts new file mode 100644 index 0000000..d281e70 --- /dev/null +++ b/src/Utils.ts @@ -0,0 +1,101 @@ + +import { type GameType } from "./Types"; + +// White = Positive, Black = Negative +export const DEFAULT_BOARD = [ + 5, 0, 0, 0, -3, 0, -5, 0, 0, 0, 0, 2, + -5, 0, 0, 0, 3, 0, 5, 0, 0, 0, 0, -2, +]; + +export function rollDie() { + return Math.floor(Math.random() * 6) + 1; +} + +export function vibrate() { + navigator.vibrate?.([50, 100, 60, 60, 90, 40, 110, 20, 150]); +} + +export const newGame = (oldGame?: GameType) => ({ + status: "", + board: [...(oldGame?.board || DEFAULT_BOARD)], + dice: oldGame?.dice || [6, 6], + prison: oldGame?.prison || { + black: 0, + white: 0, + }, + home: oldGame?.home || { + black: 0, + white: 0, + }, +} as GameType); + +export function calculate(state: GameType, from: number | "white" | "black", to: number) { + if (from === to) return { state }; // no move + const nextGame: GameType = newGame(state); + let moveLabel: string; // @see https://en.wikipedia.org/wiki/Backgammon_notation + if (from === "white") { + // white re-enter + if (nextGame.board[to] === -1) { + // hit + moveLabel = `bar/${to}*`; + nextGame.prison.black++; + nextGame.prison.white--; + nextGame.board[to] = 1; + } else if (nextGame.board[to] >= -1) { + // move + moveLabel = `bar/${to}`; + nextGame.prison.white--; + nextGame.board[to]++; + } else { + // blocked + return { state }; + } + } else if (from === "black") { + // black re-enter + if (nextGame.board[to] === 1) { + // hit + moveLabel = `bar/${to}*`; + nextGame.prison.white++; + nextGame.prison.black--; + nextGame.board[to] = -1; + } else if (nextGame.board[to] <= 1) { + // move + moveLabel = `bar/${to}`; + nextGame.prison.black--; + nextGame.board[to]--; + } else { + // blocked + return { state }; + } + } else { + const offense = nextGame.board[from]; + const defense = nextGame.board[to]; + + if (defense === undefined) { + // bear off + moveLabel = `${from}/off`; + if (offense > 0) { + nextGame.home.white++; + } else { + nextGame.home.black++; + } + } else if (!defense || Math.sign(defense) === Math.sign(offense)) { + // move + moveLabel = `${from}/${to}`; + nextGame.board[to] += Math.sign(offense); + } else if (Math.abs(defense) === 1) { + // hit + moveLabel = `${from}/${to}*`; + nextGame.board[to] = -Math.sign(defense); + if (offense > 0) nextGame.prison.black++; + else nextGame.prison.white++; + } else { + // blocked + return { state }; + } + + // remove from previous position + nextGame.board[from] -= Math.sign(nextGame.board[from]); + } + return { state: nextGame, moveLabel }; +} \ No newline at end of file diff --git a/src/Game/dice.html b/src/dice.html similarity index 84% rename from src/Game/dice.html rename to src/dice.html index 1e6ee3b..038816e 100644 --- a/src/Game/dice.html +++ b/src/dice.html @@ -1,3 +1,4 @@ + @@ -53,14 +54,14 @@
    -
    -
    -
    -
    -
    -
    -
    -
    +
    +
    +
    +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/firebaseConfig.ts @@ -0,0 +1 @@ + diff --git a/src/Game/images/dice.ts b/src/images/dice.ts similarity index 100% rename from src/Game/images/dice.ts rename to src/images/dice.ts diff --git a/src/Game/images/digit-1-black.png b/src/images/digit-1-black.png similarity index 100% rename from src/Game/images/digit-1-black.png rename to src/images/digit-1-black.png diff --git a/src/Game/images/digit-1-white.png b/src/images/digit-1-white.png similarity index 100% rename from src/Game/images/digit-1-white.png rename to src/images/digit-1-white.png diff --git a/src/Game/images/digit-2-black.png b/src/images/digit-2-black.png similarity index 100% rename from src/Game/images/digit-2-black.png rename to src/images/digit-2-black.png diff --git a/src/Game/images/digit-2-white.png b/src/images/digit-2-white.png similarity index 100% rename from src/Game/images/digit-2-white.png rename to src/images/digit-2-white.png diff --git a/src/Game/images/digit-3-black.png b/src/images/digit-3-black.png similarity index 100% rename from src/Game/images/digit-3-black.png rename to src/images/digit-3-black.png diff --git a/src/Game/images/digit-3-white.png b/src/images/digit-3-white.png similarity index 100% rename from src/Game/images/digit-3-white.png rename to src/images/digit-3-white.png diff --git a/src/Game/images/digit-4-black.png b/src/images/digit-4-black.png similarity index 100% rename from src/Game/images/digit-4-black.png rename to src/images/digit-4-black.png diff --git a/src/Game/images/digit-4-white.png b/src/images/digit-4-white.png similarity index 100% rename from src/Game/images/digit-4-white.png rename to src/images/digit-4-white.png diff --git a/src/Game/images/digit-5-black.png b/src/images/digit-5-black.png similarity index 100% rename from src/Game/images/digit-5-black.png rename to src/images/digit-5-black.png diff --git a/src/Game/images/digit-5-white.png b/src/images/digit-5-white.png similarity index 100% rename from src/Game/images/digit-5-white.png rename to src/images/digit-5-white.png diff --git a/src/Game/images/digit-6-black.png b/src/images/digit-6-black.png similarity index 100% rename from src/Game/images/digit-6-black.png rename to src/images/digit-6-black.png diff --git a/src/Game/images/digit-6-white.png b/src/images/digit-6-white.png similarity index 100% rename from src/Game/images/digit-6-white.png rename to src/images/digit-6-white.png diff --git a/src/Game/images/piece-black-2-sh.png b/src/images/piece-black-2-sh.png similarity index 100% rename from src/Game/images/piece-black-2-sh.png rename to src/images/piece-black-2-sh.png diff --git a/src/Game/images/piece-black-2.png b/src/images/piece-black-2.png similarity index 100% rename from src/Game/images/piece-black-2.png rename to src/images/piece-black-2.png diff --git a/src/Game/images/piece-black.png b/src/images/piece-black.png similarity index 100% rename from src/Game/images/piece-black.png rename to src/images/piece-black.png diff --git a/src/Game/images/piece-white-2-sh.png b/src/images/piece-white-2-sh.png similarity index 100% rename from src/Game/images/piece-white-2-sh.png rename to src/images/piece-white-2-sh.png diff --git a/src/Game/images/piece-white-2.png b/src/images/piece-white-2.png similarity index 100% rename from src/Game/images/piece-white-2.png rename to src/images/piece-white-2.png diff --git a/src/Game/images/piece-white.png b/src/images/piece-white.png similarity index 100% rename from src/Game/images/piece-white.png rename to src/images/piece-white.png diff --git a/src/index.css b/src/index.css index 5577bd5..87c642f 100644 --- a/src/index.css +++ b/src/index.css @@ -54,14 +54,13 @@ body, } } -.modal { - position: absolute; - padding: .5em 1em; +dialog { z-index: 1; - bottom: 40px; - right: 40px; background: rgba(255, 255, 255, 0.4); backdrop-filter: blur(10px); + header h1:first-of-type { + margin-block-start: 0; + } /* Portrait layout (mobile) */ @media (max-aspect-ratio: 1) { top: 100px; diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..962fe5d --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,277 @@ +import { StrictMode, useEffect, useState, useCallback, type DragEventHandler } from "react"; +import ReactDOM from 'react-dom/client' +import firebase from 'firebase/compat/app'; +import 'firebase/compat/database'; +// TODO: Upgrade to modular after firebaseui upgrades +// import { initializeApp } from 'firebase/app'; +import type { Match, Move, GameType, SnapshotOrNullType, UserData, ModalState } from "./Types"; +import Friends from "./Friends"; +import Chat from "./Chat"; +import Profile from "./Profile"; +import Login from "./Login"; +import Dice from './Dice'; +import Point from './Point'; +import Piece from './Piece'; +import './index.css' +import './Board.css'; +import './Toolbar.css' +import { calculate, newGame, rollDie, vibrate } from './Utils'; + +// Start Firebase +firebase.initializeApp({ + apiKey: "AIzaSyDSTc5VVNNT32jRE4m8qr7hVbI8ahaIsRc", + authDomain: "peaceinthemiddleeast.firebaseapp.com", + databaseURL: "https://peaceinthemiddleeast-default-rtdb.firebaseio.com", + projectId: "peaceinthemiddleeast", + storageBucket: "peaceinthemiddleeast.appspot.com", + messagingSenderId: "529824094542", + appId: "1:529824094542:web:eadc5cf0dc140a2b0de61f", + measurementId: "G-NKGPNTLDF1" +}); + +// Start React +ReactDOM.createRoot(document.getElementById('root')!).render() + +// React App +export function App() { + const database = firebase.database(); + const [game, setGame] = useState(newGame); + const [user, setUser] = useState(null); + const [state, setState] = useState(false); + const [lastState, setLastState] = useState('friends'); + const [match, setMatch] = useState(null); + const [chats, setChats] = useState(null); + const [friend, setFriend] = useState(null); + const [selected, setSelected] = useState(null); + + const toggle = useCallback((newState: ModalState) => { + if (newState === true) { + setState(prevState => { + if (prevState) setLastState(prevState); + return lastState; + }); + } else if (newState === false) { + setState(prevState => { + if (prevState) setLastState(prevState); + return false; + }); + } else { + setState(prevState => { + if (prevState) setLastState(prevState); + return newState + }); + } + }, [lastState]); + + const load = useCallback(async (userId?: string) => { + console.log('Loading', userId); + + if (!user || !userId) { + setMatch(null); + setChats(null); + setFriend(null); + return; + } + + window.history.pushState(null, '', `${userId}`); + const userSnapshot = await database.ref(`users/${userId}`).get(); + if (!userSnapshot.exists()) { + console.error('User not found', userId); + return; + } + + setFriend(userSnapshot); + const matchSnapshot = await database.ref(`matches/${user.key}/${userId}`).get(); + if (!matchSnapshot.exists()) { + // Create new match + const gameRef = database.ref('games').push(); + const chatRef = database.ref('chats').push(); + // Point match to game + const data: Match = { + sort: new Date().toISOString(), + game: gameRef.key!, + chat: chatRef.key!, + }; + database.ref(`matches/${user.key}/${userId}`).set(data); + database.ref(`matches/${userId}/${user.key}`).set(data); + setMatch(data); + } else { + setMatch(await matchSnapshot.val()) + } + toggle(false); + }, [user]); + + const reset = useCallback(() => { + if (confirm('Are you sure you want to reset the match?')) { + console.log('Resetting', match?.game); + let data = newGame() + if (match?.game) + firebase.database().ref(`games/${match?.game}`).set(data); + setGame(data); + } + }, [match?.game]) + + + // Autoload Match upon Login + useEffect(() => { + if (!user) return; + + const friendLocation = location.pathname.split('/').pop() + if (friendLocation && friendLocation !== 'PeaceInTheMiddleEast') load(friendLocation); + }, [load, user]); + + // onLogin/Logout + useEffect(() => { + const unregisterAuthObserver = firebase.auth().onAuthStateChanged(async authUser => { + if (authUser) { + const userRef = firebase.database().ref(`users/${authUser.uid}`) + let snapshot = await userRef.get() + if (!snapshot.exists()) { + // Upload initial user data + const data: UserData = { + uid: authUser.uid, + name: authUser.displayName || authUser.uid, + photoURL: authUser.photoURL, + language: navigator.language, + }; + console.log('Creating user', data); + userRef.set(data); + } + userRef.on('value', setUser); + } else { + setUser(null); + } + }); + return () => unregisterAuthObserver(); + }, []); + + useEffect(() => { + if (match?.game) { + const gameRef = firebase.database().ref(`games/${match?.game}`) + const onValue = (snapshot: firebase.database.DataSnapshot) => { + const value = snapshot.val(); + if (value) { + setGame(value); + } else { + const blankGame = newGame(); + setGame(blankGame); + // TODO: do i need to set this? + gameRef.set(blankGame); + } + }; + gameRef.on("value", onValue); + return () => { + gameRef.off("value", onValue); + }; + } + }, [match?.game]); + + const rollDice = useCallback(() => { + const newDice = [rollDie(), rollDie()]; + setGame(game => ({ ...game, dice: newDice })); + const audio = new Audio('./shake-and-roll-dice-soundbible.mp3'); + audio.play(); + vibrate(); + if (match?.game) + firebase.database().ref(`games/${match?.game}/dice`).set(newDice); + }, [match?.game]); + + const move = useCallback((from: number | "white" | "black", to: number) => { + const { state: nextState, moveLabel } = calculate(game, from, to); + if (!moveLabel) return; + setGame(nextState); + // dispatch({ type: Actions.MOVE, data: { from, to } }); + // sendMove(nextState, `${nextState.dice.join("-")}: ${moveLabel}`); + // const move = `${nextState.dice.join("-")}: ${moveLabel}` + // }; + // const move = useCallback((game: GameType, move: Move["move"]) => { + if (match?.game) { + const time = new Date().toISOString(); + const nextMove: Move = { + player: user?.val().uid, + game: match.game, + move: `${nextState.dice.join("-")}: ${moveLabel}`, + time, + } + const update = { + sort: time, + }; + database.ref('moves').push(nextMove) + database.ref(`games/${match.game}`).set(nextState) + database.ref(`matches/${user?.key}/${friend?.key}`).update(update); + database.ref(`matches/${friend?.key}/${user?.key}`).update(update); + + } + }, [game, match?.game, user, friend]); + + const onDragOver: DragEventHandler = useCallback((event) => { event.preventDefault(); }, []) + const onDrop: DragEventHandler = useCallback((event) => { + event.preventDefault(); + let from = parseInt(event.dataTransfer?.getData("text")!) + return move(from, -1,) + }, [move]) + + const onSelect = useCallback((position: number | null) => { + if (position === null || selected === position) { + setSelected(null); + } else if (selected === null) { + setSelected(position); + } else { + move(selected, position); + setSelected(null); + } + }, [selected, move]) + + const renderFriend = friend ?

    {friend.val().name}

    : null; + + const toggleFriends = useCallback(() => { toggle(!state) }, [state]) + + return ( + <> + + {user + ? state === 'friends' + ? + : state === 'profile' + ? + : state === 'chat' + ? + : null + : } + + +
    +
    + account_circle + {renderFriend} +
    + + + +
    + {Array.from({ length: game.prison.white }, (_, index) => + + )} +
    +
    + {Array.from({ length: game.prison.black }, (_, index) => + + )} +
    +
    + {Array.from({ length: game.home.black }, (_, index) => + + )} +
    +
    + {Array.from({ length: game.home.white }, (_, index) => + + )} +
    + {game.board.map((pieces: number, index: number) => + + )} +
    + + ); +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx deleted file mode 100644 index 3d7150d..0000000 --- a/src/main.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import './index.css' - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - , -)