diff --git a/frontend/public/assets/vm-shot-2026-08-07T19-27-16-851Z.jpg b/frontend/public/assets/vm-shot-2026-08-07T19-27-16-851Z.jpg new file mode 100644 index 0000000..3edc0d3 Binary files /dev/null and b/frontend/public/assets/vm-shot-2026-08-07T19-27-16-851Z.jpg differ diff --git a/frontend/public/assets/vm-shot-2026-08-07T19-27-26-780Z.jpg b/frontend/public/assets/vm-shot-2026-08-07T19-27-26-780Z.jpg new file mode 100644 index 0000000..3edc0d3 Binary files /dev/null and b/frontend/public/assets/vm-shot-2026-08-07T19-27-26-780Z.jpg differ diff --git a/frontend/public/assets/vm-shot-2026-08-07T19-28-03-715Z.jpg b/frontend/public/assets/vm-shot-2026-08-07T19-28-03-715Z.jpg new file mode 100644 index 0000000..3edc0d3 Binary files /dev/null and b/frontend/public/assets/vm-shot-2026-08-07T19-28-03-715Z.jpg differ diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 6548433..2ef67e9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 5cb10e8..cdc330c 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,13 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/play', + label: 'Play Game', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiGamepadVariant' in icon ? icon['mdiGamepadVariant' as keyof typeof icon] : icon.mdiTable, + }, { href: '/users/users-list', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index ae1bc95..af5d53e 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,166 @@ - -import React, { useEffect, useState } from 'react'; +import React from 'react'; import type { ReactElement } from 'react'; import Head from 'next/head'; import Link from 'next/link'; -import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +const colorBursts = [ + 'bg-[#35F2C1]', + 'bg-[#FF4FD8]', + 'bg-[#FFD166]', + 'bg-[#7C5CFF]', + 'bg-[#38BDF8]', + 'bg-[#FF7A59]', +]; + +const arenaStats = [ + ['49', 'color blocks'], + ['4', 'rivals'], + ['45s', 'rounds'], +]; + +const floatingBlocks = [ + 'left-[6%] top-[18%] h-14 w-14 rotate-12 bg-[#FF4FD8] shadow-[#B8228D]', + 'right-[8%] top-[17%] h-16 w-16 -rotate-12 bg-[#FFD166] shadow-[#B88711]', + 'left-[12%] bottom-[14%] h-12 w-12 -rotate-6 bg-[#38BDF8] shadow-[#117DAA]', + 'right-[18%] bottom-[18%] h-10 w-10 rotate-6 bg-[#7C5CFF] shadow-[#4931B8]', +]; export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('image'); - const [contentPosition, setContentPosition] = useState('left'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'Steal a Color Game' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; - return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('Steal a Color')} + - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+
+
+
+
+
+
+
+ + {floatingBlocks.map((block) => ( +
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
+
+
+
+
+
+

Live board

+

Preview Arena

+
+ 00:45 +
-
+
+
+ + +
+
+ {Array.from({ length: 49 }).map((_, index) => { + const color = colorBursts[(index * 5 + Math.floor(index / 7)) % colorBursts.length]; + return ( +
+ ); + })} +
+
+ +
+ {[ + ['You', '31%', '#35F2C1', '#159875'], + ['Pixel', '26%', '#FF4FD8', '#B8228D'], + ['Volt', '23%', '#FFD166', '#B88711'], + ['Nova', '20%', '#7C5CFF', '#4931B8'], + ].map(([name, score, color, shade]) => ( +
+
+

{name}

+

{score} control

+
+ ))} +
+
+
+
+
+
+ ); } Starter.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/play.tsx b/frontend/src/pages/play.tsx new file mode 100644 index 0000000..9e86147 --- /dev/null +++ b/frontend/src/pages/play.tsx @@ -0,0 +1,471 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import Link from 'next/link'; +import LayoutGuest from '../layouts/Guest'; +import { getPageTitle } from '../config'; + +type Owner = 0 | 1 | 2 | 3; +type Phase = 'setup' | 'playing' | 'finished'; + +type Player = { + id: Owner; + name: string; + color: string; + textColor: string; + glow: string; + shade: string; +}; + +type MatchRecord = { + id: string; + playerName: string; + score: number; + winner: string; + won: boolean; + captured: number; + createdAt: string; + board: Owner[]; +}; + +const boardSize = 49; +const matchSeconds = 45; +const historyKey = 'steal-a-color-history'; + +const players: Player[] = [ + { id: 0, name: 'You', color: '#35F2C1', textColor: 'text-[#087560]', glow: 'shadow-[#159875]/35', shade: '#159875' }, + { id: 1, name: 'Pixel', color: '#FF4FD8', textColor: 'text-[#B8228D]', glow: 'shadow-[#B8228D]/30', shade: '#B8228D' }, + { id: 2, name: 'Volt', color: '#FFD166', textColor: 'text-[#9B6A00]', glow: 'shadow-[#B88711]/30', shade: '#B88711' }, + { id: 3, name: 'Nova', color: '#7C5CFF', textColor: 'text-[#4931B8]', glow: 'shadow-[#4931B8]/30', shade: '#4931B8' }, +]; + +const createBoard = () => + Array.from({ length: boardSize }, (_, index) => { + if (index === 0 || index === 1 || index === 7) { + return 0; + } + + if (index === 5 || index === 6 || index === 13) { + return 1; + } + + if (index === 35 || index === 42 || index === 43) { + return 2; + } + + if (index === 41 || index === 47 || index === 48) { + return 3; + } + + return ((index + Math.floor(index / 7)) % 4) as Owner; + }); + +const getCounts = (board: Owner[]) => + players.map((player) => ({ + ...player, + tiles: board.filter((owner) => owner === player.id).length, + })); + +const loadHistory = (): MatchRecord[] => { + if (typeof window === 'undefined') { + return []; + } + + try { + const stored = window.localStorage.getItem(historyKey); + if (!stored) { + return []; + } + + const parsed = JSON.parse(stored); + return Array.isArray(parsed) ? parsed : []; + } catch (error) { + console.error('Failed to load Steal a Color match history', error); + return []; + } +}; + +const saveHistory = (records: MatchRecord[]) => { + try { + window.localStorage.setItem(historyKey, JSON.stringify(records)); + } catch (error) { + console.error('Failed to save Steal a Color match history', error); + } +}; + +const formatDate = (value: string) => + new Intl.DateTimeFormat('en', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(value)); + +export default function PlayStealAColor() { + const [phase, setPhase] = useState('setup'); + const [playerName, setPlayerName] = useState('Player One'); + const [board, setBoard] = useState(createBoard); + const [secondsLeft, setSecondsLeft] = useState(matchSeconds); + const [captured, setCaptured] = useState(0); + const [history, setHistory] = useState([]); + const [selectedMatchId, setSelectedMatchId] = useState(null); + const [message, setMessage] = useState('Name your blocky avatar, start a match, then smack rival color blocks to steal them.'); + + useEffect(() => { + const records = loadHistory(); + setHistory(records); + setSelectedMatchId(records[0]?.id ?? null); + }, []); + + const counts = useMemo(() => getCounts(board), [board]); + const leader = useMemo(() => [...counts].sort((a, b) => b.tiles - a.tiles)[0], [counts]); + const playerScore = counts[0]?.tiles ?? 0; + const selectedMatch = history.find((record) => record.id === selectedMatchId) ?? history[0]; + + const finishMatch = useCallback( + (finalBoard: Owner[]) => { + const finalCounts = getCounts(finalBoard); + const winner = [...finalCounts].sort((a, b) => b.tiles - a.tiles)[0]; + const record: MatchRecord = { + id: `${Date.now()}`, + playerName: playerName.trim() || 'Player One', + score: finalCounts[0].tiles, + winner: winner.name, + won: winner.id === 0, + captured, + createdAt: new Date().toISOString(), + board: finalBoard, + }; + const nextHistory = [record, ...history].slice(0, 6); + setPhase('finished'); + setMessage(winner.id === 0 ? 'Victory! Your color owns the arena.' : `${winner.name} stole the crown. Hit rematch and take it back.`); + setHistory(nextHistory); + setSelectedMatchId(record.id); + saveHistory(nextHistory); + }, + [captured, history, playerName] + ); + + useEffect(() => { + if (phase !== 'playing') { + return undefined; + } + + if (secondsLeft <= 0) { + finishMatch(board); + return undefined; + } + + const timer = window.setTimeout(() => setSecondsLeft((current) => current - 1), 1000); + return () => window.clearTimeout(timer); + }, [board, finishMatch, phase, secondsLeft]); + + useEffect(() => { + if (phase !== 'playing') { + return undefined; + } + + const aiTimer = window.setInterval(() => { + setBoard((currentBoard) => { + const nextBoard = [...currentBoard]; + const ai = (1 + Math.floor(Math.random() * 3)) as Owner; + const candidates = nextBoard + .map((owner, index) => ({ owner, index })) + .filter((tile) => tile.owner !== ai); + + if (candidates.length === 0) { + return currentBoard; + } + + const target = candidates[Math.floor(Math.random() * candidates.length)]; + nextBoard[target.index] = ai; + return nextBoard; + }); + }, 1050); + + return () => window.clearInterval(aiTimer); + }, [phase]); + + const startMatch = () => { + const safeName = playerName.trim(); + if (!safeName) { + setMessage('Please add a player name before starting.'); + return; + } + + setBoard(createBoard()); + setSecondsLeft(matchSeconds); + setCaptured(0); + setPhase('playing'); + setMessage(`Go ${safeName}! Tap any rival block to flip it mint.`); + }; + + const stealTile = (index: number) => { + if (phase !== 'playing') { + setMessage('Press Start first to unlock the arena.'); + return; + } + + setBoard((currentBoard) => { + if (currentBoard[index] === 0) { + setMessage('That block already belongs to you. Find a rival color.'); + return currentBoard; + } + + const nextBoard = [...currentBoard]; + nextBoard[index] = 0; + setCaptured((current) => current + 1); + setMessage('Steal confirmed! Keep the mint wave rolling.'); + return nextBoard; + }); + }; + + const clearHistory = () => { + setHistory([]); + setSelectedMatchId(null); + saveHistory([]); + setMessage('Local match history cleared. The arena is reset.'); + }; + + return ( + <> + + {getPageTitle('Play Steal a Color')} + + +
+
+
+
+
+
+
+
+ + + {players.map((player) => ( + + ))} + + + Steal a Color + Block Arena + + + + +
+ +
+ + +
+
+
+

Arena

+

Tap-to-steal blocks

+
+
+
+ Time + 0:{String(secondsLeft).padStart(2, '0')} +
+
+ Your blocks + {playerScore} +
+
+
+ +
+ {counts.map((player) => ( +
+ +

{player.name === 'You' ? playerName || 'You' : player.name}

+

{player.tiles}

+
+ ))} +
+ +
+ {board.map((owner, index) => { + const tileOwner = players[owner]; + return ( + + ); + })} +
+ + {phase === 'finished' ? ( +
+

Results

+

+ {leader.id === 0 ? 'You won the color crown!' : `${leader.name} wins this round.`} +

+

+ {playerName.trim() || 'Player One'} finished with {playerScore} blocks and made {captured} manual steals. +

+
+ ) : null} +
+ + +
+
+
+
+ + ); +} + +PlayStealAColor.getLayout = function getLayout(page: ReactElement) { + return {page}; +};