diff --git a/.github/workflows/assign-on-comment.yml b/.github/workflows/assign-on-comment.yml new file mode 100644 index 0000000..cc691c0 --- /dev/null +++ b/.github/workflows/assign-on-comment.yml @@ -0,0 +1,18 @@ +# .github/workflows/take.yml +name: Assign issue to contributor +on: + issue_comment: + +jobs: + assign: + name: Take an issue + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: take the issue + uses: bdougie/take-action@main + with: + message: Thanks for taking this issue! Let us know if you have any questions! + trigger: /assign + token: ${{ secrets.ISSUE_TOKEN }} \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d56e8ab..f96f292 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,3 +1,9 @@ +# Game rules +To be on the same page, we will be following [these rules](https://www.unorules.com/). + +--- + + # Communication between the client and the server Apart from the initial request-response exchange for initial setup, @@ -19,9 +25,8 @@ Simply put, long polling is a technique where the client sends a request to the # Protocol for transmitting game events Each action by the player, like drawing a card from deck, throwing a -card, announcing UNO, etc., is represented as a message. The client -sends these messages to the server via POST at the `/events` -endpoint, and the server sends these messages to all clients. The +card, announcing UNO, etc., is represented as an event. The client sends these event messages to the server via POST at the `/events` endpoint. +endpoint, and the server sends these messages to all clients through the long polling system. The format of the message is: ```json @@ -33,7 +38,54 @@ format of the message is: } } ``` -Other possible values for `type` are `THROW_CARD`, `ANNOUNCE_UNO`, etc. When such a request reaches the server, the server updates the game state and sends the message to all clients (through the polling mechanism). The clients update their game state accordingly, and make the necessary changes to their UI and game state. +We should design the code paths such that it would be easy to switch to WebSockets in the upcoming weeks. + +## Game Events +The following events will be handled by the game engine: +### `JOIN_GAME` +Sent by the client when a player joins the game. All the other clients should be notified about the new player, so that they can update their UI. +### `LEAVE_GAME` +Self explanatory. +### `START_GAME` +There needs to be some discussion on how we want to handle this. We can either start the game when all players have joined, or we can start the game when the host decides to start the game. We can also have a ready button for each player, and the game starts when all players are ready. +### `THROW_CARD` +Sent by the client when a player throws a card. The server will validate the move and update the game state accordingly. +### `DRAW_CARD` +Sent by the client when a player draws a card from the deck. + +### `ANNOUNCE_UNO` +Sent by the client when a player announces UNO. We expect a button to pop up when a player has only one card left. This should be handled by the client. +### `CHALLENGE_UNO` +Sent by the client when a player challenges another player's UNO. +### `DRAW_4_CHALLENGE` +Read about this in the game rules. This is related to `CHALLENGE_UNO` implementation wise. + +Note that the server will always notify all the clients about the game events. The clients will update their game state and UI accordingly. + +We will add more rules once these are implemented. +The data pertaining to these events will be decided while working on the events. +All the game event handling functions can reside in a directory inside the `uno-game-engine` directory. + +--- + +# API Structure +We will use express routers to group similar endpoints. The API structure will be as follows: + +``` +/api + /v1 + /auth + /login + /register + /game + /create + /join + /leave + /start + /end + /events +``` +The routers will be defined in the `routes` directory. The controllers will be defined in the `controllers` directory. The controllers will handle the business logic and call the game engine functions to handle the game events. All the controller functions should be wrapped in the error handling higher order function. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b37432..01c2b5a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,7 @@ Given that you have already forked the repository and set it up locally: ```bash git fetch upstream - git checkout -b upstream/main + git checkout -b upstream/master ``` 6. **Implement Your Fix**: @@ -60,7 +60,7 @@ Given that you have already forked the repository and set it up locally: ``` 10. **Open a Pull Request**: - - Open a pull request (PR) against the `main` branch of the original repository. Provide a clear description of your changes and reference the issue number you are fixing. Fill the self review checklist. You should only solve one issue in one PR. + - Open a pull request (PR) against the `master` branch of the original repository. Provide a clear description of your changes and reference the issue number you are fixing. Fill the self review checklist. You should only solve one issue in one PR. 11. **Address Review Comments**: - If maintainers suggests changes to your code, make the necessary updates and push the changes to your branch. The fix/changes should not be in a separate commit - rather the original commit must be modified force-pushed to the branch. If merge conflicts arise, use `git rebase` to resolve them. See the section on [editing commit history](#editing-commit-history) for more details. @@ -70,9 +70,9 @@ Given that you have already forked the repository and set it up locally: ## Points to remember during contribution - Editing commit history and rebasing are very valuable tools for keeping the commit history clean and easy to understand. Please familiarize yourself with these concepts before contributing. In any case, the seniors will be there to help you out. -- Before starting work, run `git fetch upstream` and then `git rebase upstream/master`, to rebase your branch on top of the main branch. This will help you avoid merge conflicts, and sync your branch with the main branch. +- Before starting work, run `git fetch upstream` and then `git rebase upstream/master`, to rebase your branch on top of the master branch. This will help you avoid merge conflicts, and sync your branch with the master branch. - Addressing reviews on existing PRs is more important than creating new PRs. Please be responsive to the feedback and make the necessary updates. -- Create a new branch for each issue you are working on. This will help you keep your changes isolated and make it easier to manage multiple PRs. The branch should be created from upstream/master, and only after fetching the latest changes from the main branch from upstream first. +- Create a new branch for each issue you are working on. This will help you keep your changes isolated and make it easier to manage multiple PRs. The branch should be created from upstream/master, and only after fetching the latest changes from the master branch from upstream first. ## How to make a good Pull Request - Make sure your PR is solving only one issue. If you are solving multiple issues, create separate PRs for each issue. @@ -83,6 +83,7 @@ Given that you have already forked the repository and set it up locally: - ESLint checks (There should be no eslint errors at least in the files you have modified.) - Prettier checks - Unit tests + - The commit history should be clean, concise and descriptive. It must match the format specified in [CONVENTIONS.md](CONVENTIONS.md#commit-message-guidelines). ## Common Git Operations you may need to perform @@ -169,6 +170,20 @@ All contributions go through a code review process to ensure the quality and mai When you open a pull request, you can request a review from the maintainers. You can also request a review after making changes in response to feedback. The requested reviewer may then review the PR themselves or delegate it to another maintainer. When requesting a review, make sure that your PR doesn't have merge conflicts. If it does, resolve the conflicts before requesting a review. +After a round of code review, it is your duty to: +- Reply to all comments made by the reviewer. If you disagree with a comment, you can discuss it with the reviewer. +- Make the necessary changes to your code. +- Tag the reviewer in the PR thread if you want to discuss something with them. +- Request a review again after making the changes. (Using the `Request Review` button on the PR page.) + +Any change suggested by the reviewer should be made in the same commit, unless specified. The commit message should +be a summary of the changes you made. (Do not include things like `removed comment after review`). + + +# NOTE +- If you committed a bug, do not make another commit fixing it. Instead, amend the original commit using `git commit --amend`. This will add the changes to the original commit. +- Add tests for a function implemented, in the same commit as the implementation. + Your PR will be merged only after the maintainers approve it. Different areas of codebase are handled by different maintainers. ## Code of Conduct diff --git a/backend/controllers/gameControllers.js b/backend/controllers/gameControllers.js deleted file mode 100644 index a4d73c4..0000000 --- a/backend/controllers/gameControllers.js +++ /dev/null @@ -1 +0,0 @@ -// Implement the handler for `/events` endpoint. Refer to ARCHITECTURE.md for implementation details. diff --git a/backend/eventRecipients.ts b/backend/eventRecipients.ts deleted file mode 100644 index 3861c85..0000000 --- a/backend/eventRecipients.ts +++ /dev/null @@ -1,18 +0,0 @@ -// this module is responsible for handling the clients currently connected to the server. -// It stores the clients in a Map object, where the key is the client's user_id and the value is the client's http response object. - -import { Response } from 'express'; - -const clients = new Map(); - -export function addClient(userId: string, res: Response) { - clients.set(userId, res); -} - -export function removeClient(userId: string) { - clients.delete(userId); -} - -export function getClient(userId: string) { - return clients.get(userId); -} diff --git a/backend/src/controllers/gameControllers.js b/backend/src/controllers/gameControllers.js new file mode 100644 index 0000000..14cdcde --- /dev/null +++ b/backend/src/controllers/gameControllers.js @@ -0,0 +1,21 @@ +import { getAllClients, scheduleSend } from '../eventRecipients'; + +async function handleEvent(req, res) { + const event = req.body; + + const clientIds = getAllClients(); + + const eligibleClientIds = filterEligibleClients(clientIds); + + eligibleClientIds.forEach((clientId) => { + scheduleSend(clientId, event); + }); + + res.status(200).send({ message: 'Event propagated to clients.' }); +} + +function filterEligibleClients(clientIds) { + return clientIds; +} + +export default handleEvent; diff --git a/backend/src/eventRecipients.ts b/backend/src/eventRecipients.ts new file mode 100644 index 0000000..c96fefd --- /dev/null +++ b/backend/src/eventRecipients.ts @@ -0,0 +1,49 @@ +// this module is responsible for handling the clients currently connected to the server. +// It stores the clients in a Map object, where the key is the client id and the value is the +// client's http response object. +// When sending an event, we first push the event to a queue. We need to use a queue because it +// is possible that the client hasn't sent the next polling request yet, i.e, we don't have a response +// object for that client yet. When the client sends the next polling request, we will send the events +// from the queue to the client. + +import { Response } from 'express'; + +type ClientId = string; + +const clients = new Map(); +const eventQueue = new Map(); + +export function addClient(clientId: ClientId, res: Response) { + // todo: We can immediately send any events that are in the queue. + clients.set(clientId, res); + eventQueue.set(clientId, []); +} + +export function removeClient(clientId: ClientId) { + clients.delete(clientId); + eventQueue.delete(clientId); +} + +export function getClient(clientId: ClientId) { + return clients.get(clientId); +} + +export function getAllClients(): ClientId[] { + return Array.from(clients.keys()); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function scheduleSend(clientId: ClientId, event: AppEvent) { + //todo: Enqueue the event for sending. +} + +export function doSendEvents(clientId: ClientId) { + const res = clients.get(clientId); + const events = eventQueue.get(clientId) || []; + + if (res && events.length > 0) { + res.json({ events }); + eventQueue.set(clientId, []); + clients.delete(clientId); + } +} diff --git a/backend/gameStore.ts b/backend/src/gameStore.ts similarity index 100% rename from backend/gameStore.ts rename to backend/src/gameStore.ts diff --git a/backend/routes/gameRoutes.js b/backend/src/routes/gameRoutes.ts similarity index 51% rename from backend/routes/gameRoutes.js rename to backend/src/routes/gameRoutes.ts index fa43827..7864512 100644 --- a/backend/routes/gameRoutes.js +++ b/backend/src/routes/gameRoutes.ts @@ -1,10 +1,13 @@ import express from 'express'; import { addClient } from '../eventRecipients'; +import handleEvent from '../controllers/gameControllers'; + const router = express.Router(); router.get('/events', (req, res) => { addClient('user_id', res); }); -// the post handler should retrieve the game the user is currently in, and update the game state. -// The request body contains the event data, as described in ARCHITECTURE.md +router.post('/events', handleEvent); + +export default router; diff --git a/backend/src/types.d.ts b/backend/src/types.d.ts index 4f21a45..a75b4e8 100644 --- a/backend/src/types.d.ts +++ b/backend/src/types.d.ts @@ -3,12 +3,12 @@ type CardType = 'number' | 'special' | 'wild'; -type CardColor = 'red' | 'blue' | 'green' | 'yellow'; +type CardColor = 'red' | 'blue' | 'green' | 'yellow' | 'wild'; -type SpecialCardNames = 'skip' | 'reverse' | 'draw2' | 'draw4' | 'colchange'; -type CardNumbers = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'; +type SpecialCardName = 'skip' | 'reverse' | 'draw2' | 'draw4' | 'colchange'; +type CardNumber = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'; -type CardValue = SpecialCardNames | CardNumbers; +type CardValue = SpecialCardName | CardNumber; type UNOCard = { type: CardType; @@ -44,4 +44,7 @@ type GameEvent = card: UNOCard; }; }; + +// Represent all the events that can be sent to the client +type AppEvent = GameEvent; //todo: Add more events diff --git a/backend/uno-game-engine/deck.ts b/backend/src/uno-game-engine/deck.ts similarity index 71% rename from backend/uno-game-engine/deck.ts rename to backend/src/uno-game-engine/deck.ts index 8a6c19e..5571f8a 100644 --- a/backend/uno-game-engine/deck.ts +++ b/backend/src/uno-game-engine/deck.ts @@ -1,5 +1,5 @@ -const colors: Array = ['red', 'yellow', 'green', 'blue']; -const values = [ +const colors: Array = ['red', 'yellow', 'green', 'blue', 'wild']; +const numValues: Array = [ '0', '1', '2', @@ -10,12 +10,9 @@ const values = [ '7', '8', '9', - 'skip', - 'reverse', - 'draw2', ]; -const specialCards = ['wild', 'draw4']; -const deck = []; +const specialValues: Array = ['skip', 'reverse', 'draw2']; +const wildCardValues: Array = ['colchange', 'draw4']; const sameCardCount = []; // to keep track of same cards in assigning unique id to each card /** @@ -42,11 +39,28 @@ const sameCardCount = []; // to keep track of same cards in assigning unique id @returns {Array} deck - An array of 108 UNO cards. */ export function getShuffledCardDeck(): Array { - const deck = []; - // todo: Implement the card generation logic - // dummy code: - // deck.push(makeCard('special', 'wild', 'wild')) - // deck.push(makeCard('number', 'red', '0')) + const deck: Array = []; + + colors.forEach((color) => { + if (color === 'wild') { + wildCardValues.forEach((value) => { + for (let i = 0; i < 4; i++) { + deck.push(makeCard('wild', color, value)); + } + }); + } else { + numValues.forEach((value) => { + deck.push(makeCard('number', color, value)); + if (value !== '0') { + deck.push(makeCard('number', color, value)); + } + }); + specialValues.forEach((value) => { + deck.push(makeCard('special', color, value)); + deck.push(makeCard('special', color, value)); + }); + } + }); shuffle(deck); return deck; diff --git a/backend/uno-game-engine/engine.ts b/backend/src/uno-game-engine/engine.ts similarity index 56% rename from backend/uno-game-engine/engine.ts rename to backend/src/uno-game-engine/engine.ts index 17bf2fe..0c97d04 100644 --- a/backend/uno-game-engine/engine.ts +++ b/backend/src/uno-game-engine/engine.ts @@ -1,4 +1,4 @@ -import { getShuffledCardDeck } from './deck'; +import { getShuffledCardDeck, shuffle } from './deck'; import { handleEvent } from './gameEvents'; const NUM_CARDS_PER_PLAYER = 7; @@ -47,11 +47,35 @@ export class GameEngine { this.currentPlayerIndex = (this.currentPlayerIndex + this.direction) % this.players.length; } - drawCardFromDeck(player: Player) { - //todo: Handle the case when the deck is empty and we have to move the thrown cards back to the deck - this.players - .find((p: Player) => p.id === player.id) - .cards.push(this.cardDeck.pop()); + drawCardFromDeck(player: Player): EventResult { + try { + if (this.cardDeck.length === 0) { + this.cardDeck = [...this.thrownCards]; + this.thrownCards = []; + shuffle(this.cardDeck); + } + const currentPlayer = this.players.find( + (p: Player) => p.id === player.id + ); + if (currentPlayer && this.cardDeck) { + const card = this.cardDeck.pop(); + if (card) { + currentPlayer.cards.push(card); + return { + type: 'SUCCESS', + message: 'Card drawn successfully', + }; + } else + return { type: 'ERROR', message: 'Unable to draw a card' }; + } else { + return { + type: 'ERROR', + message: 'Player not found or cardDeck is empty', + }; + } + } catch (error) { + return { type: 'ERROR', message: (error as Error).message }; + } } dispatchEvent(event: GameEvent) { // handle different types of events based on event.type diff --git a/backend/uno-game-engine/gameEvents.ts b/backend/src/uno-game-engine/gameEvents.ts similarity index 100% rename from backend/uno-game-engine/gameEvents.ts rename to backend/src/uno-game-engine/gameEvents.ts diff --git a/backend/src/utils.ts b/backend/src/utils.ts new file mode 100644 index 0000000..316e774 --- /dev/null +++ b/backend/src/utils.ts @@ -0,0 +1,13 @@ +import { Request, Response } from 'express'; + +type ControllerFunction = (req: Request, res: Response) => Promise; + +export function catchError(fn: ControllerFunction): ControllerFunction { + return async function (req: Request, res: Response) { + try { + return await fn(req, res); + } catch (error) { + res.status(500).json({ error: error }); + } + }; +} diff --git a/backend/tests/deck.test.ts b/backend/tests/deck.test.ts index d54e7b3..686b7c4 100644 --- a/backend/tests/deck.test.ts +++ b/backend/tests/deck.test.ts @@ -1,10 +1,68 @@ -import { shuffle, makeCard } from '../uno-game-engine/deck'; +import { + shuffle, + makeCard, + getShuffledCardDeck, +} from '../src/uno-game-engine/deck'; + +describe('getShuffledCardDeck', () => { + test('should return an array of 108 cards', () => { + const deck = getShuffledCardDeck(); + expect(deck.length).toBe(108); + { + // Check the number of cards of each type + const numberCards = deck.filter((card) => card.type === 'number'); + expect(numberCards.length).toBe(76); + + const specialCards = deck.filter((card) => card.type === 'special'); + expect(specialCards.length).toBe(24); + + const wildCards = deck.filter((card) => card.type === 'wild'); + expect(wildCards.length).toBe(8); + } + { + // Check the number of cards of red color + const redCards = deck.filter((card) => card.color === 'red'); + expect(redCards.length).toBe(25); // 25 * 4 = 100 color cards + } + { + // Check the number of cards of 0 and 1 values + const zeroValueCards = deck.filter((card) => card.value === '0'); + expect(zeroValueCards.length).toBe(4); + + const oneValueCards = deck.filter((card) => card.value === '1'); + expect(oneValueCards.length).toBe(8); + } + { + // Check the number of cards of special type + const skipCards = deck.filter((card) => card.value === 'skip'); + expect(skipCards.length).toBe(8); + + const draw2Cards = deck.filter((card) => card.value === 'draw2'); + expect(draw2Cards.length).toBe(8); + + const reverseCards = deck.filter( + (card) => card.value === 'reverse' + ); + expect(reverseCards.length).toBe(8); + } + { + // Check the number of cards of wild type + const wildCards = deck.filter((card) => card.value === 'colchange'); + expect(wildCards.length).toBe(4); + + const draw4Cards = deck.filter((card) => card.value === 'draw4'); + expect(draw4Cards.length).toBe(4); + } + }); +}); + describe('testing deck.ts', () => { test('makeCard', () => { const card = makeCard('number', 'blue', '3'); expect(card.color).toBe('blue'); }); }); + describe('shuffle function', () => { test('should change order of elements', () => { // Create a mock deck diff --git a/backend/tests/engine.test.ts b/backend/tests/engine.test.ts new file mode 100644 index 0000000..2d6225f --- /dev/null +++ b/backend/tests/engine.test.ts @@ -0,0 +1,21 @@ +import { GameEngine } from '../src/uno-game-engine/engine'; + +describe('testing drawCardFromDeck()', () => { + test('draws a card when deck is empty but thrownCards is not', () => { + const game = new GameEngine(); + game.addPlayer({ id: '1', cards: [] }); + game.cardDeck = []; + game.thrownCards = [ + { + type: 'number', + color: 'yellow', + value: '1', + id: 'card-number-yellow-1-1', + }, + ]; + const player = game.players[0]; + const status: EventResult = game.drawCardFromDeck(player); + expect(player.cards.length).toBe(1); + expect(status.type).toBe('SUCCESS'); + }); +}); diff --git a/frontend/public/UNO_Logo.svg b/frontend/public/UNO_Logo.svg new file mode 100644 index 0000000..278a3ed --- /dev/null +++ b/frontend/public/UNO_Logo.svg @@ -0,0 +1,175 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/tornCard.svg b/frontend/src/assets/tornCard.svg new file mode 100644 index 0000000..41c7436 --- /dev/null +++ b/frontend/src/assets/tornCard.svg @@ -0,0 +1,310 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/index.css b/frontend/src/index.css index b5c61c9..a168df0 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1,7 @@ @tailwind base; @tailwind components; @tailwind utilities; + +body::-webkit-scrollbar { + display: none; +} diff --git a/frontend/src/library/button.tsx b/frontend/src/library/button.tsx index d9f5396..319076e 100644 --- a/frontend/src/library/button.tsx +++ b/frontend/src/library/button.tsx @@ -11,7 +11,14 @@ type ButtonProps = { }; function Button({ onClick, children }: ButtonProps) { - return ; + return ( + + ); } export default Button; diff --git a/frontend/src/library/heading.tsx b/frontend/src/library/heading.tsx index e69de29..7878f12 100644 --- a/frontend/src/library/heading.tsx +++ b/frontend/src/library/heading.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +type InputProps = { + text: string; + fontSize?: string; + fontWeight?: string; + fontStyle?: string; + textColor?: string; + textAlign?: string; + className?: string; +}; + +const Heading: React.FC = ({ + text, + fontSize = 'text-sm', // default value + fontWeight = 'font-normal', // default value + fontStyle = 'font-sans', // default value + textColor = 'text-black', // default value + textAlign = 'text-center', // default value + className = '', // additional custom classes +}) => { + return ( +
+

+ {text} +

+
+ ); +}; + +export default Heading; diff --git a/frontend/src/library/toast/Toast.tsx b/frontend/src/library/toast/Toast.tsx new file mode 100644 index 0000000..90e69cc --- /dev/null +++ b/frontend/src/library/toast/Toast.tsx @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ToastContext } from './toast-context'; +import './toast.css'; + +function useTimeout(callback: () => void, duration: number) { + const savedCallback = useRef(callback); + + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + useEffect(() => { + const functionId = setTimeout(() => savedCallback.current(), duration); + + return () => { + clearTimeout(functionId); + }; + }, [duration]); +} +type toastProperties = { + message: string; + close: () => void; + duration: number; + position: string; + color: string; +}; + +export function Toast({ + message, + close, + duration, + position, + color, +}: toastProperties) { + useTimeout(() => { + close(); + }, duration); + return ( +
+

{message}

+ +
+ ); +} + +type ToastProviderProperties = { + children: React.ReactElement; +}; +type ToastType = { + message: string; + id: number; + duration: number; + position: string; + color: string; +}; + +export function ToastProvider({ children }: ToastProviderProperties) { + const [toasts, setToasts] = useState([]); + const [position, setPosition] = useState('top-left'); + type Options = { + message?: string; + duration?: number; + position?: string; + color?: 'info' | 'warning' | 'error' | 'success'; + }; + const openToast = useCallback( + ({ + message = '', + duration = 5000, + position = 'top-center', + color = 'info', + }: Options = {}) => { + const newToast = { + message: message, + id: Date.now(), + duration: duration, + position: position, + color: color, + }; + setToasts((prevToast) => [...prevToast, newToast]); + setPosition(position); + }, + [] + ); + + const closeToast = useCallback((id: number) => { + setTimeout(() => { + setToasts((prevToasts) => + prevToasts.filter((toast) => toast.id !== id) + ); + }, 300); + + setToasts((toasts) => { + return toasts.map((toast) => { + if (toast.id === id) { + if (toast.position == 'top-left') + toast.position = 'fade-out-left'; + else if (toast.position == 'top-right') + toast.position = 'fade-out-right'; + else if (toast.position == 'top-center') + toast.position = 'fade-out-center'; + } + return toast; + }); + }); + }, []); + const contextValue = useMemo( + () => ({ + open: openToast, + close: closeToast, + }), + [openToast, closeToast] + ); + return ( + + {children} +
+ {toasts && + toasts.map((toast) => { + return ( + { + closeToast(toast.id); + }} + duration={toast.duration} + position={toast.position} + color={toast.color} + /> + ); + })} +
+
+ ); +} diff --git a/frontend/src/library/toast/toast-context.ts b/frontend/src/library/toast/toast-context.ts new file mode 100644 index 0000000..625bc97 --- /dev/null +++ b/frontend/src/library/toast/toast-context.ts @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; + +type Options = { + message?: string; + duration?: number; + position?: string; + color?: 'info' | 'warning' | 'error' | 'success'; +}; + +type ToastContextValue = { + open: (options?: Options) => void; + close: (id: number) => void; +}; + +export const ToastContext = createContext({ + open: () => {}, + close: () => {}, +}); + +export const useToast = () => useContext(ToastContext); diff --git a/frontend/src/library/toast/toast.css b/frontend/src/library/toast/toast.css new file mode 100644 index 0000000..42cf727 --- /dev/null +++ b/frontend/src/library/toast/toast.css @@ -0,0 +1,181 @@ +.toasts { + display: flex; + flex-direction: column; + gap: 10px; +} + +.toast { + color: black; + border-radius: 5px; + padding: 10px 10px; + width: 300px; + position: relative; + display: flex; +} + +.top-right { + position: fixed; + top: 10px; + right: 10px; +} + +.top-left { + position: fixed; + top: 10px; + left: 10px; +} +.top-center { + position: fixed; + top: 10px; + left: 38%; +} + +.top-center-animation { + animation-name: slideinCenter; + animation-duration: 0.35s; +} + +.top-right-animation { + animation-name: slideinRight; + animation-duration: 0.35s; +} + +.top-left-animation { + animation-name: slideinLeft; + animation-duration: 0.35s; +} + +@keyframes slideinRight { + 0% { + transform: translateX(100%); + } + 60% { + transform: translateX(-15%); + } + 80% { + transform: translateX(5%); + } + 80% { + transform: translateX(0); + } +} +@keyframes slideinCenter { + 0% { + transform: translateY(-100%); + } + 60% { + transform: translateY(15%); + } + 80% { + transform: translateY(-5%); + } + 80% { + transform: translateY(0); + } +} + +@keyframes slideinLeft { + 0% { + transform: translateX(-100%); + } + 60% { + transform: translateX(15%); + } + 80% { + transform: translateX(-5%); + } + 80% { + transform: translateX(0); + } +} + +.fade-out-left-animation { + animation-name: fade-out-left; + animation-duration: 0.35s; +} + +.fade-out-right-animation { + animation-name: fade-out-right; + animation-duration: 0.35s; +} + +.fade-out-center-animation { + animation-name: fade-out-center; + animation-duration: 0.35s; +} + +@keyframes fade-out-left { + 0% { + transform: translateX(0%); + } + + 60% { + transform: translateX(-100%); + } + + 80% { + transform: translateX(-195%); + } + + 100% { + transform: translateX(-200%); + } +} +@keyframes fade-out-right { + 0% { + transform: translateX(0%); + } + + 60% { + transform: translateX(100%); + } + + 80% { + transform: translateX(195%); + } + + 100% { + transform: translateX(200%); + } +} + +@keyframes fade-out-center { + 0% { + transform: translateY(-100%); + } + 30% { + transform: translateY(-300%); + } + 80% { + transform: translateY(-700%); + } + 100% { + transform: translateY(-1000%); + } +} + +.info { + background-color: cyan; +} + +.success { + background-color: #5cb85c; +} + +.error { + background-color: #d9534f; +} +.warning { + background-color: #f0ad4e; +} + +.close-button { + position: absolute; + right: 0px; + top: 0px; + padding: 0px 5px; + background: none; + cursor: pointer; + border: transparent; + color: black; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 12afe2b..a3323d8 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,9 +2,12 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App.tsx'; import './index.css'; +import { ToastProvider } from './library/toast/Toast.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( - + + + ); diff --git a/frontend/src/pages/Error.tsx b/frontend/src/pages/Error.tsx index ca9bbef..56ca451 100644 --- a/frontend/src/pages/Error.tsx +++ b/frontend/src/pages/Error.tsx @@ -1,21 +1,32 @@ import { Link } from 'react-router-dom'; +import ErrorImage from '../assets/tornCard.svg'; function Error() { return ( - <> -
-
-
-

404

-
-
Oops! Looks like there's an error
-
- Back to Home -
+
+
+
+

404

+

+ Page Not Found +

+
+ 404 +
+

+ Sorry, the page you're looking for can't be found. + Please return to the Homepage! +

+
- - +
); } diff --git a/frontend/src/pages/PlayOptions.tsx b/frontend/src/pages/PlayOptions.tsx index 6600ba6..ef531b1 100644 --- a/frontend/src/pages/PlayOptions.tsx +++ b/frontend/src/pages/PlayOptions.tsx @@ -1,26 +1,77 @@ +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import Input from '../library/input'; import Button from '../library/button'; +import Heading from '../library/heading'; +import '../index.css'; -function PlayOptions() { +const PlayOptions = () => { + const [gameCode, setGameCode] = useState(''); const navigate = useNavigate(); - function handleCreateGame() { - console.log('Creating a new game'); + const CreateGame = () => { + // Logic to create a game + console.log('Create Game'); navigate('/game'); - } + }; + + const JoinGame = () => { + // Logic to join a game + console.log('Join Game with code:', gameCode); + }; return ( - <> -
-

Create a new game

- -
-
-

Join a game

- +
+
+
+ UNO Logo + +
+
+
+
+ +
+ +
+
+
+ +
+
+ setGameCode(e.target.value)} + className="border-2 border-red-600 rounded-lg p-1 mb-4 text-md w-full bg-black text-white" + /> +
+ +
+
- +
); -} +}; export default PlayOptions;