├── .env.example ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bun.lock ├── components.json ├── eslint.config.mjs ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── assets │ ├── icons │ │ ├── menu │ │ │ ├── menu-achievement.webp │ │ │ ├── menu-multiplayer.webp │ │ │ ├── menu-quest.webp │ │ │ ├── menu-singleplayer.webp │ │ │ └── menu-store.webp │ │ └── mode │ │ │ ├── mode-easy.webp │ │ │ ├── mode-hard.webp │ │ │ └── mode-medium.webp │ └── not-found.webp ├── cursor │ ├── cursor-idle.webp │ └── cursor-pointer.webp ├── icon │ ├── icon-128x128.png │ ├── icon-144x144.png │ ├── icon-152x152.png │ ├── icon-192x192.png │ ├── icon-384x384.png │ ├── icon-48x48.png │ ├── icon-512x512.png │ ├── icon-72x72.png │ └── icon-96x96.png ├── manifest.json ├── metadata │ ├── manifest.webp │ ├── readme-1.webp │ ├── readme-2.webp │ ├── readme-3.webp │ ├── readme-4.webp │ ├── readme-5.webp │ ├── readme-6.webp │ ├── readme-7.webp │ ├── readme-8.webp │ └── readme-9.webp ├── sw.js └── workbox-1bb06f5e.js ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── capabilities │ └── default.json ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── src │ ├── lib.rs │ └── main.rs └── tauri.conf.json ├── src ├── app │ ├── api │ │ └── discord-webhook │ │ │ └── route.ts │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── not-found.tsx │ └── page.tsx ├── components │ ├── CustomDock.tsx │ ├── MainMenu.tsx │ ├── Minesweeper.tsx │ ├── Timer.tsx │ ├── TooltipWrapper.tsx │ ├── Web3Connect.tsx │ ├── magicui │ │ ├── confetti.tsx │ │ ├── dock.tsx │ │ ├── particles.tsx │ │ ├── shimmer-button.tsx │ │ └── sparkles-text.tsx │ ├── mainmenu │ │ ├── AchievementCard.tsx │ │ └── QuestCard.tsx │ ├── modal │ │ ├── AchievementModal.tsx │ │ ├── GameResultModal.tsx │ │ ├── MultiplayerModal.tsx │ │ ├── QuestModal.tsx │ │ ├── SettingsModal.tsx │ │ └── SingleplayerModal.tsx │ └── ui │ │ ├── alert.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── tabs.tsx │ │ └── tooltip.tsx ├── configs │ ├── game.tsx │ ├── index.ts │ ├── metadata.ts │ ├── mock.ts │ └── settings.ts ├── hooks │ └── useTranslation.tsx ├── lib │ └── utils.ts ├── locales │ ├── en.json │ ├── jp.json │ ├── th.json │ ├── vi.json │ └── zh.json ├── providers │ ├── FontProvider.tsx │ ├── LanguageProvider.tsx │ ├── SolanaProvider.tsx │ ├── ThemeProvider.tsx │ └── Web3Provider.tsx ├── stores │ ├── commonStore.ts │ ├── gameStore.ts │ ├── index.ts │ ├── languageStore.ts │ ├── settingStore.ts │ └── walletStore.ts ├── types │ ├── css.d.ts │ ├── index.d.ts │ └── store.d.ts └── utils │ └── discord-webhook.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Basic Settings 2 | NODE_ENV= # production | development 3 | ANALYZE= # true | false 4 | 5 | # Discord 6 | DISCORD_SHARE_WEBHOOK= 7 | 8 | # Web3 Connect 9 | NETWORK_RPC=devnet # mainnet | testnet | devnet -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | !.env.example 36 | 37 | # vercel 38 | .vercel 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | next-env.d.ts 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Minesweeper Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## 📍 [1.2.7] - 2025-03-05 5 | ### Fixed 6 | - error share discord on webhook (env issue) 7 | 8 | ## 📍 [1.2.6] - 2025-03-05 9 | ### Added 10 | - package: axios & @radix-ui/react-tabs 11 | - image: menu & mode icon in mainmenu 12 | - font: each languages 13 | - added link Discord Channel in Dock Menu 14 | - added: Shared on discord server system (only Wallet connect) 15 | - added: Multiplayer Menu (MOCKUP Only) 16 | - added: Achievement Menu (MOCKUP Only) 17 | - added: Quests Menu (MOCKUP Only) 18 | - added: Store Menu (Nothing inside) 19 | - added: Icon for Singleplayer each mode. 20 | 21 | ### Changed 22 | - updated: .env.example (add DISCORD_SHARE_WEBHOOK) 23 | - The font will change according to the selected language. 24 | - updated: UX/UI Mainmenu 25 | - Organize MainMenu & Modal files more neatly. 26 | 27 | ## 📍 [1.2.5] - 2025-03-04 28 | ### Fixed 29 | - change web3 wallet connect autoConnect: false > true 30 | 31 | ## 📍 [1.2.4] - 2025-03-04 32 | ### Added 33 | - package: crypto-browserify & path-browserify 34 | 35 | ### Changed 36 | - next.config.ts (assetPrefix & webpack) 37 | 38 | ### Fixed 39 | - Fixed: Web3 Solana wallet connect issue on Desktop version 40 | 41 | ## 📍 [1.2.3] - 2025-03-04 42 | ### Added 43 | - Web3 connect (solana) 44 | - package: @solana/wallet-adapter-base 45 | - package: @solana/wallet-adapter-react 46 | - package: @solana/wallet-adapter-react-ui 47 | - package: @solana/wallet-adapter-wallets 48 | - package: @solana/web3.js 49 | - package: pino-pretty 50 | - package: @tauri-apps/api 51 | - package: @tauri-apps/plugin-opener 52 | 53 | ### Changed 54 | - not-found.webp image 55 | 56 | ## 📍 [1.2.2] - 2025-03-01 57 | 58 | ### Fixed 59 | - Fixed: Viewport (Lighthouse Accessibility score) 60 | - Fixed: VersionGame color (Lighthouse Accessibility score) 61 | 62 | ## 📍 [1.2.1] - 2025-03-01 63 | 64 | ### Fixed 65 | - Game logic: Calculate score 66 | - Game system: When press restart button, not reset Flag placed. 67 | 68 | ## 📍 [1.2.0] - 2025-03-01 69 | Version 1.2.0 adds a PWA system for offline mobile play and integrates Tauri for cross-platform export, allowing the game to run on all platforms (desktop, macOS, Android, iOS, Linux, and web). 70 | 71 | Additionally, we have refined the game logic for a more polished experience and introduced a new sharing system. 72 | 73 | ### Added 74 | - package: @tauri-apps/cli & next-pwa & @types/next-pwa 75 | - script: tauri 76 | - setup: tauri for cross-platform application 77 | - setup: next-pwa 78 | - added: Viewport (next) 79 | - Mainmenu > Game Info > added link CHANGELOG.md 80 | - setting: added menu change Flag icon color 81 | - added: Flag country icon (Language setting) 82 | 83 | ### Changed 84 | - move package to devDependencies: @next/bundle-analyzer & @tailwindcss/postcss & autoprefixer & next-themes & tailwind-merge & tailwindcss-animate 85 | - NextThemeProvider changed defaultTheme "system" > "light" 86 | - Refactor code: SettingsModal.tsx (reusable more / SelectField) 87 | - next.config.ts for withPWA 88 | - updated: Share game result clipboard > save image .png file 89 | - updated: Game Result share image added board game. 90 | 91 | ### Fixed 92 | - Fixed: Gamelogic when pressing Reset button, the time does not reset to 0. 93 | - Fixed: Gamelogic does not remove the flag when opening a tile that has no surrounding mines but has a flag placed on it. 94 | - MainMenu change import "version" to "pkg" for fixed issue Compiled. 95 | 96 | ## 📍 [1.1.0] - 2025-03-01 97 | Here we would have the update steps for 1.1.0 for people to follow. 98 | 99 | In this version, there are significant changes as we aimed to make it more developable and easier to maintain. We've revamped several systems to achieve consistency, with the main changes being the upgrade of TailwindCSS to version 4.0.9 and the implementation of Zustand for global state management instead of passing props across pages. 100 | 101 | ### Added 102 | - file: .env.example & CHANGELOG.md 103 | - package: @next/bundle-analyzer & @tailwindcss/postcss & autoprefixer & tailwindcss-animate & zustand 104 | - file: /public/assets/not-found.webp 105 | - Global State with zustand 106 | ### Changed 107 | - setup: "next.config.ts" bundle-analyzer & images 108 | - package: upgrade tailwindcss 3.4.1 > 4.0.9 109 | - edit README.md (TailwindCSS version) 110 | - refactor: local storage > global state managment with zustand 111 | ### Fixed 112 | - postcss.config.mjs & tailwind.config.ts & global.css for TailwindCSS 4.0.9 113 | - Fixed game logic after playing can continue playing for 1 turn. 114 | - Fixed Theme issue. 115 | 116 | ## 📍 [1.0.0] - 2025-02-14 117 | Release first version - Minesweeper Game with Next.js 15 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Suvijak (Guy) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Project Banner](/public/metadata/readme-1.webp) 2 | # Minesweeper – Old school game with Next.JS 3 | A simple implementation of the classic Minesweeper game built with Next.js. This project showcases the use of React components and modern front-end development techniques. Players can enjoy the Minesweeper game experience directly in their browser, with a clean and responsive UI. 4 | 5 | ![Project Banner](/public/metadata/readme-2.webp) 6 | ![Project Banner](/public/metadata/readme-3.webp) 7 | ![Project Banner](/public/metadata/readme-4.webp) 8 | 9 | ## 📌 Project Overview 10 | Minesweeper is a web-based implementation of the classic puzzle game, built with Next.js 15. It features modern UI elements, customizable settings, and responsive design. Players can enjoy the classic Minesweeper experience with added features like custom themes, multiple languages, and various visual customization options. 11 | 12 | ![Project Banner](/public/metadata/readme-9.webp) 13 | 14 | ## ⚡ Key Points to Know 15 | - 🎮 **Classic Gameplay** - Traditional Minesweeper mechanics with modern features 16 | - 🌙 **Dark/Light Mode** - Full theme support with automatic system detection 17 | - 🌍 **Multi-Language** - Supports English, Thai, Japanese, Vietnamese, and Chinese (can add more) 18 | - 📱 **Responsive Design** - Playable on all devices from mobile to desktop 19 | - 🎨 **Customizable UI** - Various options for icons, numbers, and game elements (can add more) 20 | - 💾 **No Backend** - Client-side only with state management 21 | 22 | ## 🔥 Features 23 | - 🎯 **Multiple Difficulty Levels** - Easy (9x9), Medium (16x16) and Hard (16x30) 24 | - 🎨 **Customizable Elements** (can add more) 25 | - Different flag icons (Default, Pyramid, Radar, Sparkles, Sigma) 26 | - Various mine icons (Bomb, Skull, Fire, Flame, Ghost) 27 | - Multiple number styles (Default, Roman, Thai, ABC, Question) 28 | - 🌍 **Language Support** - Available in multiple languages (can add more) 29 | - 🎨 **Theme Options** - Dark and light mode with system preference detection 30 | - 📊 **Score System** - Time-based scoring with difficulty multipliers 31 | - 📱 **Mobile Support** - Touch-friendly interface with flag mode toggle 32 | 33 | ## 🛠️ Tech Stack 34 | - nextjs Next.js 15 – Framework for static site generation. 35 | - tailwindcss TailwindCSS 4 – Utility-first CSS framework for styling. 36 | - typscript TypeScript – Strongly typed JavaScript for better maintainability. 37 | - shadcn-ui shadcn/ui – Reusable UI components 38 | - magic-ui Magic UI – Reusable UI components 39 | - lucide-icons Lucide Icons – Modern icon set 40 | - next-themes next-themes – Theme management 41 | 42 | ![Project Banner](/public/metadata/readme-8.webp) 43 | 44 | ## 🚀 Live Demo 45 | Try it here: [Minesweeper](https://nextjs-minesweeper-game.vercel.app) 46 | 47 | ![Project Banner](/public/metadata/readme-5.webp) 48 | ![Project Banner](/public/metadata/readme-6.webp) 49 | ![Project Banner](/public/metadata/readme-7.webp) 50 | 51 | ## 📂 Installation & Setup 52 | 53 | To run this project locally, follow these steps: 54 | 55 | ### **1. Clone the repository** 56 | ```bash 57 | git clone https://github.com/guysuvijak/nextjs-minesweeper-game.git 58 | ``` 59 | ```bash 60 | cd nextjs-minesweeper-game 61 | ``` 62 | ### **2. Install dependencies** 63 | ```bash 64 | npm install 65 | or 66 | bun install 67 | or 68 | pnpm install 69 | ``` 70 | ### **3. Start the development server** 71 | ```bash 72 | npm run dev 73 | or 74 | bun run dev 75 | or 76 | pnpm run dev 77 | ``` 78 | ### **4. The app will be available** 79 | [http://localhost:3000](http://localhost:3000) 80 | ### **5. Export to cross-platform** 81 | ```bash 82 | npm run tauri 83 | or 84 | bun run tauri 85 | or 86 | pnpm run tauri 87 | ``` 88 | note: If you want to Export Cross-platform, please follow setup before [Here](https://v2.tauri.app/start/prerequisites/) 89 | 90 | ## 🗺️ Project Structure 91 | ```bash 92 | nextjs-minesweeper-game/ 93 | ├── public/ # Static assets 94 | │ ├── cursor/ # Static Cursor Image 95 | │ ├── icon/ # Website Icon 96 | │ ├── metadata/ # Static Metadata & README Image 97 | │ └── manifest.json # Config Metadata (PWA) 98 | └── src/ 99 | ├── app/ # layout & page Next.JS 100 | │ └── page.tsx # Main Page 101 | ├── components/ # React components 102 | │ ├── magicui/ # UI from Magic-UI 103 | │ ├── ui/ # UI from shadcn/ui 104 | │ ├── CustomDock.tsx # Menu Dock in Mainmenu 105 | │ ├── GameResultModal.tsx # Modal Result in Game Screen 106 | │ ├── MainMenu.tsx # Mainmenu Screen 107 | │ ├── Minesweeper.tsx # Game Screen 108 | │ ├── SettingsModal.tsx # Modal Settings in Mainmenu 109 | │ └── Timer.tsx # Timer in Game Screen 110 | ├── configs/ # Configs File (Edit Here) 111 | ├── hooks/ # React Custom Hooks 112 | ├── lib/ # Auto Create from Library 113 | ├── locales/ # Language Files 114 | └── types/ # TypeScript File 115 | ``` 116 | 117 | 118 | ## 📄 Modifying the Website 119 | Since this is a static website, all modifications must be made directly within the project files. Here’s where you can edit specific parts of the site: 120 | ```bash 121 | ⚙️ Game Configuration 122 | 📍 File: src/configs/game.tsx 123 | Add or modify game settings like difficulty levels, scoring, and colors. 124 | ``` 125 | ```bash 126 | 🎨 UI Settings 127 | 📍 File: src/configs/settings.ts 128 | Configure available options for icons, themes, and number styles. 129 | ``` 130 | ```bash 131 | 🌍 Language Files 132 | 📍 Folder: src/locales/ 133 | 📍 File: src/configs/language.ts 134 | Add or modify translations for different languages. 135 | ``` 136 | ```bash 137 | 🖼️ Custom Cursors 138 | 📍 Folder: public/cursor/ 139 | Add or modify custom cursor images. 140 | ``` 141 | ```bash 142 | ⚙️ Types Definition 143 | 📍 File: src/types/index.ts 144 | Modify TypeScript types and interfaces. 145 | ``` 146 | ```bash 147 | ⚙️ Website Configuration 148 | 📍 Files: 149 | src/configs/metadata.ts – Modify general layout and metadata settings. 150 | public/manifest.json – Update website metadata for PWA settings. 151 | ``` 152 | 153 | ## 📜 License 154 | This project is open-source under the MIT License. 155 | Let me know if you need any modifications! 🚀 156 | 157 | ## 🙏 Acknowledgments 158 | Thank you for your interest in Minesweeper from me! Your support means a lot. :heart:
159 | ⭐ If you like this project, please consider giving it a star on GitHub to show your support and encouragement! 🚀 -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | 11 | }); 12 | 13 | const eslintConfig = [ 14 | ...compat.extends("next/core-web-vitals", "next/typescript"), 15 | 16 | ]; 17 | 18 | export default eslintConfig; 19 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next'; 2 | import NextBundleAnalyzer from '@next/bundle-analyzer'; 3 | import createNextPWA from 'next-pwa'; 4 | 5 | const isProd = process.env.NODE_ENV === 'production'; 6 | 7 | const withBundleAnalyzer = NextBundleAnalyzer({ 8 | enabled: process.env.ANALYZE === 'true' 9 | }); 10 | 11 | const withPWA = createNextPWA({ 12 | dest: 'public', 13 | disable: process.env.NODE_ENV === 'development', 14 | register: true, 15 | skipWaiting: true 16 | }); 17 | 18 | const nextConfig = { 19 | output: 'export', 20 | assetPrefix: isProd ? undefined : 'http://localhost:3000', 21 | reactStrictMode: true, 22 | images: { 23 | unoptimized: true, 24 | formats: ['image/webp'], 25 | deviceSizes: [32, 64, 96], 26 | remotePatterns: [ 27 | { protocol: 'https', hostname: 'www.google.com' }, 28 | { protocol: 'https', hostname: 'nextjs-minesweeper-game.vercel.app' } 29 | ] 30 | }, 31 | webpack: (config) => { 32 | config.resolve.fallback = { 33 | fs: false, 34 | net: false, 35 | tls: false, 36 | crypto: require.resolve('crypto-browserify'), 37 | path: require.resolve('path-browserify') 38 | }; 39 | 40 | return config; 41 | } 42 | } satisfies NextConfig; 43 | 44 | export default isProd ? withPWA(nextConfig) : withBundleAnalyzer(nextConfig); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-minesweeper-game", 3 | "version": "1.2.7", 4 | "description": "A simple implementation of the classic Minesweeper game built with Next.js. This project showcases the use of React components and modern front-end development techniques. Players can enjoy the Minesweeper game experience directly in their browser, with a clean and responsive UI.", 5 | "repository": "git@github.com:guysuvijak/nextjs-minesweeper-game.git", 6 | "license": "MIT", 7 | "keywords": [ 8 | "open-source", 9 | "nextjs-game", 10 | "minesweeper", 11 | "meteorviix", 12 | "guysuvijak", 13 | "typescript", 14 | "frontend", 15 | "nextjs", 16 | "static-site", 17 | "client-side", 18 | "no-database" 19 | ], 20 | "author": "MeteorVIIx | https://github.com/guysuvijak", 21 | "private": true, 22 | "scripts": { 23 | "dev": "next dev --turbopack", 24 | "build": "next build", 25 | "start": "next start", 26 | "lint": "next lint", 27 | "tauri": "NEXT_PUBLIC_BUILD_TARGET=desktop && tauri build", 28 | "info": "tauri info" 29 | }, 30 | "dependencies": { 31 | "@radix-ui/react-dialog": "^1.1.6", 32 | "@radix-ui/react-dropdown-menu": "^2.1.6", 33 | "@radix-ui/react-label": "^2.1.2", 34 | "@radix-ui/react-select": "^2.1.6", 35 | "@radix-ui/react-separator": "^1.1.2", 36 | "@radix-ui/react-slot": "^1.1.2", 37 | "@radix-ui/react-tabs": "^1.1.3", 38 | "@radix-ui/react-tooltip": "^1.1.8", 39 | "@solana/wallet-adapter-base": "^0.9.23", 40 | "@solana/wallet-adapter-react": "^0.15.35", 41 | "@solana/wallet-adapter-react-ui": "^0.9.35", 42 | "@solana/wallet-adapter-wallets": "^0.19.32", 43 | "@solana/web3.js": "^1.98.0", 44 | "axios": "^1.8.1", 45 | "canvas-confetti": "^1.9.3", 46 | "class-variance-authority": "^0.7.1", 47 | "clsx": "^2.1.1", 48 | "html2canvas": "^1.4.1", 49 | "lucide-react": "^0.475.0", 50 | "motion": "^12.4.2", 51 | "next": "15.2.3", 52 | "pino-pretty": "^13.0.0", 53 | "react": "^19.0.0", 54 | "react-dom": "^19.0.0", 55 | "zustand": "^5.0.3" 56 | }, 57 | "devDependencies": { 58 | "@eslint/eslintrc": "^3", 59 | "@next/bundle-analyzer": "^15.2.0", 60 | "@tailwindcss/postcss": "^4.0.9", 61 | "@tauri-apps/api": "^2.3.0", 62 | "@tauri-apps/cli": "^2.3.1", 63 | "@tauri-apps/plugin-opener": "^2.2.6", 64 | "@types/canvas-confetti": "^1.9.0", 65 | "@types/next-pwa": "^5.6.9", 66 | "@types/node": "^20", 67 | "@types/react": "^19", 68 | "@types/react-dom": "^19", 69 | "autoprefixer": "^10.4.20", 70 | "crypto-browserify": "^3.12.1", 71 | "eslint": "^9", 72 | "eslint-config-next": "15.1.7", 73 | "next-pwa": "^5.6.0", 74 | "next-themes": "^0.4.4", 75 | "path-browserify": "^1.0.1", 76 | "postcss": "^8", 77 | "tailwind-merge": "^3.0.2", 78 | "tailwindcss": "^4.0.9", 79 | "tailwindcss-animate": "^1.0.7", 80 | "typescript": "^5" 81 | }, 82 | "optionalDependencies": { 83 | "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", 84 | "lightningcss-linux-x64-gnu": "^1.29.1" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | "@tailwindcss/postcss": {}, 5 | "autoprefixer": {} 6 | }, 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /public/assets/icons/menu/menu-achievement.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/assets/icons/menu/menu-achievement.webp -------------------------------------------------------------------------------- /public/assets/icons/menu/menu-multiplayer.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/assets/icons/menu/menu-multiplayer.webp -------------------------------------------------------------------------------- /public/assets/icons/menu/menu-quest.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/assets/icons/menu/menu-quest.webp -------------------------------------------------------------------------------- /public/assets/icons/menu/menu-singleplayer.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/assets/icons/menu/menu-singleplayer.webp -------------------------------------------------------------------------------- /public/assets/icons/menu/menu-store.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/assets/icons/menu/menu-store.webp -------------------------------------------------------------------------------- /public/assets/icons/mode/mode-easy.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/assets/icons/mode/mode-easy.webp -------------------------------------------------------------------------------- /public/assets/icons/mode/mode-hard.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/assets/icons/mode/mode-hard.webp -------------------------------------------------------------------------------- /public/assets/icons/mode/mode-medium.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/assets/icons/mode/mode-medium.webp -------------------------------------------------------------------------------- /public/assets/not-found.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/assets/not-found.webp -------------------------------------------------------------------------------- /public/cursor/cursor-idle.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/cursor/cursor-idle.webp -------------------------------------------------------------------------------- /public/cursor/cursor-pointer.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/cursor/cursor-pointer.webp -------------------------------------------------------------------------------- /public/icon/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/icon/icon-128x128.png -------------------------------------------------------------------------------- /public/icon/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/icon/icon-144x144.png -------------------------------------------------------------------------------- /public/icon/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/icon/icon-152x152.png -------------------------------------------------------------------------------- /public/icon/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/icon/icon-192x192.png -------------------------------------------------------------------------------- /public/icon/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/icon/icon-384x384.png -------------------------------------------------------------------------------- /public/icon/icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/icon/icon-48x48.png -------------------------------------------------------------------------------- /public/icon/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/icon/icon-512x512.png -------------------------------------------------------------------------------- /public/icon/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/icon/icon-72x72.png -------------------------------------------------------------------------------- /public/icon/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/icon/icon-96x96.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Minesweeper", 3 | "short_name": "Minesweeper", 4 | "theme_color": "#000000", 5 | "background_color": "#000000", 6 | "display": "standalone", 7 | "orientation": "portrait", 8 | "scope": "/", 9 | "start_url": "/", 10 | "icons": [ 11 | { 12 | "src": "icon/icon-48x48.png", 13 | "sizes": "48x48", 14 | "type": "image/png", 15 | "purpose": "maskable any" 16 | }, 17 | { 18 | "src": "icon/icon-72x72.png", 19 | "sizes": "72x72", 20 | "type": "image/png", 21 | "purpose": "maskable any" 22 | }, 23 | { 24 | "src": "icon/icon-96x96.png", 25 | "sizes": "96x96", 26 | "type": "image/png", 27 | "purpose": "maskable any" 28 | }, 29 | { 30 | "src": "icon/icon-128x128.png", 31 | "sizes": "128x128", 32 | "type": "image/png", 33 | "purpose": "maskable any" 34 | }, 35 | { 36 | "src": "icon/icon-144x144.png", 37 | "sizes": "144x144", 38 | "type": "image/png", 39 | "purpose": "maskable any" 40 | }, 41 | { 42 | "src": "icon/icon-152x152.png", 43 | "sizes": "152x152", 44 | "type": "image/png", 45 | "purpose": "maskable any" 46 | }, 47 | { 48 | "src": "icon/icon-192x192.png", 49 | "sizes": "192x192", 50 | "type": "image/png", 51 | "purpose": "maskable any" 52 | }, 53 | { 54 | "src": "icon/icon-384x384.png", 55 | "sizes": "384x384", 56 | "type": "image/png", 57 | "purpose": "maskable any" 58 | }, 59 | { 60 | "src": "icon/icon-512x512.png", 61 | "sizes": "512x512", 62 | "type": "image/png", 63 | "purpose": "maskable any" 64 | } 65 | ], 66 | "splash_pages": null 67 | } -------------------------------------------------------------------------------- /public/metadata/manifest.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/metadata/manifest.webp -------------------------------------------------------------------------------- /public/metadata/readme-1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/metadata/readme-1.webp -------------------------------------------------------------------------------- /public/metadata/readme-2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/metadata/readme-2.webp -------------------------------------------------------------------------------- /public/metadata/readme-3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/metadata/readme-3.webp -------------------------------------------------------------------------------- /public/metadata/readme-4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/metadata/readme-4.webp -------------------------------------------------------------------------------- /public/metadata/readme-5.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/metadata/readme-5.webp -------------------------------------------------------------------------------- /public/metadata/readme-6.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/metadata/readme-6.webp -------------------------------------------------------------------------------- /public/metadata/readme-7.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/metadata/readme-7.webp -------------------------------------------------------------------------------- /public/metadata/readme-8.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/metadata/readme-8.webp -------------------------------------------------------------------------------- /public/metadata/readme-9.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/public/metadata/readme-9.webp -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Generated by Tauri 6 | # will have schema files for capabilities auto-completion 7 | /gen/schemas 8 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nextjs-minesweeper-game" 3 | version = "1.2.7" 4 | description = "A simple implementation of the classic Minesweeper game built with Next.js. This project showcases the use of React components and modern front-end development techniques. Players can enjoy the Minesweeper game experience directly in their browser, with a clean and responsive UI." 5 | license = "MIT" 6 | authors = ["MeteorVIIx"] 7 | edition = "2021" 8 | rust-version = "1.77.2" 9 | 10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 | 12 | [lib] 13 | # The `_lib` suffix may seem redundant but it is necessary 14 | # to make the lib name unique and wouldn't conflict with the bin name. 15 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 16 | name = "nextjs_minesweeper_game_lib" 17 | crate-type = ["staticlib", "cdylib", "rlib"] 18 | 19 | [build-dependencies] 20 | tauri-build = { version = "2", features = [] } 21 | 22 | [dependencies] 23 | tauri = { version = "2", features = [] } 24 | tauri-plugin-opener = "2" 25 | serde = { version = "1", features = ["derive"] } 26 | serde_json = "1" 27 | 28 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "Capability for the main window", 5 | "windows": ["main"], 6 | "permissions": [ 7 | "core:default", 8 | "opener:default" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ 2 | #[tauri::command] 3 | fn greet(name: &str) -> String { 4 | format!("Hello, {}! You've been greeted from Rust!", name) 5 | } 6 | 7 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 8 | pub fn run() { 9 | tauri::Builder::default() 10 | .plugin(tauri_plugin_opener::init()) 11 | .invoke_handler(tauri::generate_handler![greet]) 12 | .run(tauri::generate_context!()) 13 | .expect("error while running tauri application"); 14 | } 15 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | nextjs_minesweeper_game_lib::run() 6 | } 7 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", 3 | "productName": "Minesweeper", 4 | "version": "1.2.7", 5 | "identifier": "com.nextjs-minesweeper-game.app", 6 | "build": { 7 | "frontendDist": "../out", 8 | "devUrl": "http://localhost:3000", 9 | "beforeDevCommand": "bun run dev", 10 | "beforeBuildCommand": "bun run build" 11 | }, 12 | "app": { 13 | "windows": [ 14 | { 15 | "title": "Minesweeper", 16 | "width": 800, 17 | "height": 760, 18 | "minWidth": 800, 19 | "minHeight": 760, 20 | "resizable": true, 21 | "fullscreen": false, 22 | "dragDropEnabled": false 23 | } 24 | ], 25 | "security": { 26 | "csp": "default-src 'self'; connect-src 'self' https://*.solana.com wss://*.solana.com https://*.phantom.com https://*.solflare.com; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; child-src 'self' blob: https://*.solana.com https://*.phantom.com https://*.solflare.com; frame-src 'self' https://*.solana.com https://*.phantom.com https://*.solflare.com; object-src 'none'; base-uri 'self'; form-action 'self'; font-src 'self' data:; img-src 'self' data: blob: https:; manifest-src 'self'; worker-src 'self' blob:;" 27 | } 28 | }, 29 | "bundle": { 30 | "active": true, 31 | "targets": "all", 32 | "icon": [ 33 | "icons/32x32.png", 34 | "icons/128x128.png", 35 | "icons/128x128@2x.png", 36 | "icons/icon.icns", 37 | "icons/icon.ico" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/api/discord-webhook/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import axios from 'axios'; 3 | 4 | const shareWebhook = process.env.DISCORD_SHARE_WEBHOOK as string; 5 | 6 | export async function POST(request: NextRequest) { 7 | try { 8 | const formData = await request.formData(); 9 | const publicKey = formData.get('publicKey') as string; 10 | const file = formData.get('file') as Blob; 11 | 12 | if (!file) { 13 | return NextResponse.json({ error: 'No file uploaded' }, { status: 400 }); 14 | } 15 | 16 | const discordFormData = new FormData(); 17 | const payload = { 18 | content: `Minesweeper score shared by: ${publicKey || 'Anonymous Player'}` 19 | }; 20 | 21 | discordFormData.append('payload_json', JSON.stringify(payload)); 22 | discordFormData.append('file', file, 'minesweeper-score.png'); 23 | 24 | const response = await axios.post(shareWebhook, discordFormData, { 25 | headers: { 26 | 'Content-Type': 'multipart/form-data' 27 | } 28 | }); 29 | 30 | return NextResponse.json({ success: true, status: response.status }); 31 | } catch (error) { 32 | console.error('Error sending Discord webhook:', error); 33 | return NextResponse.json({ error: 'Failed to send webhook' }, { status: 500 }); 34 | } 35 | }; -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guysuvijak/nextjs-minesweeper-game/85e79839b43c5cd0f218c70e853d3ac6a863a316/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @config "../../tailwind.config.ts"; 3 | 4 | body { 5 | cursor: url('/cursor/cursor-idle.webp'), auto; 6 | } 7 | 8 | /* Pointer cursor */ 9 | a, button, [role="button"], input[type="submit"], input[type="button"], input[type="reset"] { 10 | cursor: url('/cursor/cursor-pointer.webp'), pointer; 11 | } 12 | 13 | .cursor-default { 14 | cursor: url('/cursor/cursor-idle.webp'), pointer; 15 | } 16 | 17 | .cursor-pointer { 18 | cursor: url('/cursor/cursor-pointer.webp'), pointer; 19 | } 20 | 21 | @layer base { 22 | :root { 23 | --background: 0 0% 100%; 24 | --foreground: 0 0% 3.9%; 25 | --card: 0 0% 100%; 26 | --card-foreground: 0 0% 3.9%; 27 | --popover: 0 0% 100%; 28 | --popover-foreground: 0 0% 3.9%; 29 | --primary: 0 0% 9%; 30 | --primary-foreground: 0 0% 98%; 31 | --secondary: 0 0% 96.1%; 32 | --secondary-foreground: 0 0% 9%; 33 | --muted: 0 0% 96.1%; 34 | --muted-foreground: 0 0% 45.1%; 35 | --accent: 0 0% 96.1%; 36 | --accent-foreground: 0 0% 9%; 37 | --destructive: 0 84.2% 60.2%; 38 | --destructive-foreground: 0 0% 98%; 39 | --border: 0 0% 89.8%; 40 | --input: 0 0% 89.8%; 41 | --ring: 0 0% 3.9%; 42 | --chart-1: 12 76% 61%; 43 | --chart-2: 173 58% 39%; 44 | --chart-3: 197 37% 24%; 45 | --chart-4: 43 74% 66%; 46 | --chart-5: 27 87% 67%; 47 | --radius: 0.5rem; 48 | 49 | /* Font family variables */ 50 | --font-sans: var(--font-geist-sans), system-ui, sans-serif; 51 | --font-mono: var(--font-geist-mono), monospace; 52 | --font-thai: var(--font-thai), var(--font-geist-sans), system-ui, sans-serif; 53 | --font-japanese: var(--font-japanese), var(--font-geist-sans), system-ui, sans-serif; 54 | --font-korean: var(--font-korean), var(--font-geist-sans), system-ui, sans-serif; 55 | --font-chinese: var(--font-chinese), var(--font-geist-sans), system-ui, sans-serif; 56 | --font-vietnamese: var(--font-vietnamese), var(--font-geist-sans), system-ui, sans-serif; 57 | } 58 | .dark { 59 | --background: 0 0% 3.9%; 60 | --foreground: 0 0% 98%; 61 | --card: 0 0% 3.9%; 62 | --card-foreground: 0 0% 98%; 63 | --popover: 0 0% 3.9%; 64 | --popover-foreground: 0 0% 98%; 65 | --primary: 0 0% 98%; 66 | --primary-foreground: 0 0% 9%; 67 | --secondary: 0 0% 14.9%; 68 | --secondary-foreground: 0 0% 98%; 69 | --muted: 0 0% 14.9%; 70 | --muted-foreground: 0 0% 63.9%; 71 | --accent: 0 0% 14.9%; 72 | --accent-foreground: 0 0% 98%; 73 | --destructive: 0 62.8% 30.6%; 74 | --destructive-foreground: 0 0% 98%; 75 | --border: 0 0% 14.9%; 76 | --input: 0 0% 14.9%; 77 | --ring: 0 0% 83.1%; 78 | --chart-1: 220 70% 50%; 79 | --chart-2: 160 60% 45%; 80 | --chart-3: 30 80% 55%; 81 | --chart-4: 280 65% 60%; 82 | --chart-5: 340 75% 55%; 83 | } 84 | } 85 | 86 | @layer base { 87 | * { 88 | @apply border-border; 89 | } 90 | body { 91 | @apply bg-background text-foreground; 92 | } 93 | } 94 | 95 | @layer utilities { 96 | .scrollbar-game { 97 | scrollbar-width: thin; 98 | scrollbar-color: theme('colors.gray.400') theme('colors.gray.100'); 99 | } 100 | 101 | .scrollbar-game::-webkit-scrollbar { 102 | width: 6px; 103 | height: 6px; 104 | } 105 | 106 | .scrollbar-game::-webkit-scrollbar-track { 107 | background: theme('colors.gray.100'); 108 | border-radius: 3px; 109 | } 110 | 111 | .scrollbar-game::-webkit-scrollbar-thumb { 112 | background-color: theme('colors.gray.400'); 113 | border-radius: 3px; 114 | } 115 | } 116 | 117 | * { 118 | -webkit-user-select: none; 119 | -moz-user-select: none; 120 | -ms-user-select: none; 121 | user-select: none; 122 | } 123 | 124 | input, textarea { 125 | -webkit-user-select: text; 126 | -moz-user-select: text; 127 | -ms-user-select: text; 128 | user-select: text; 129 | } 130 | 131 | /* Font classes */ 132 | .font-sans { 133 | font-family: var(--font-sans); 134 | } 135 | 136 | .font-mono { 137 | font-family: var(--font-mono); 138 | } 139 | 140 | .font-thai { 141 | font-family: var(--font-thai); 142 | } 143 | 144 | .font-japanese { 145 | font-family: var(--font-japanese); 146 | } 147 | 148 | .font-korean { 149 | font-family: var(--font-korean); 150 | } 151 | 152 | .font-chinese { 153 | font-family: var(--font-chinese); 154 | } 155 | 156 | .font-vietnamese { 157 | font-family: var(--font-vietnamese); 158 | } -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { VIEWPORT, METADATA } from '@/configs'; 2 | import { ThemeProvider } from '@/providers/ThemeProvider'; 3 | import { LanguageProvider } from '@/providers/LanguageProvider'; 4 | import { Web3Provider } from '@/providers/Web3Provider'; 5 | import { FontProvider } from '@/providers/FontProvider'; 6 | import './globals.css'; 7 | 8 | export const viewport = VIEWPORT; 9 | export const metadata = METADATA; 10 | 11 | const RootLayout = ({children}: {children: React.ReactNode}) => { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | {children} 21 |
22 |
23 |
24 |
25 | 26 | 27 | ) 28 | }; 29 | 30 | export default RootLayout; -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import Image from 'next/image'; 3 | import Link from 'next/link'; 4 | import { useTranslation } from '@/hooks/useTranslation'; 5 | import { Button } from '@/components/ui/button'; 6 | import { Card, CardContent } from '@/components/ui/card'; 7 | import { useState, useEffect } from 'react'; 8 | 9 | const NotFoundPage = () => { 10 | const id = 'not-found'; 11 | const { t } = useTranslation(); 12 | const [ isLoading, setIsLoading ] = useState(true); 13 | 14 | useEffect(() => { 15 | setIsLoading(false); 16 | }, []); 17 | 18 | if (isLoading) { 19 | return ( 20 |
21 | {t(`${id}.loading`)} 22 |
23 | ) 24 | } 25 | 26 | return ( 27 |
28 | 29 | 30 | Not Found 37 |

{t(`${id}.title`)}

38 |

{t(`${id}.description`)}

39 | 42 |
43 |
44 |
45 | ); 46 | }; 47 | 48 | export default NotFoundPage; -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { MainMenu } from '@/components/MainMenu'; 3 | import { Minesweeper } from '@/components/Minesweeper'; 4 | import { SingleplayerModal } from '@/components/modal/SingleplayerModal'; 5 | import { MultiplayerModal } from '@/components/modal/MultiplayerModal'; 6 | import { AchievementModal } from '@/components/modal/AchievementModal'; 7 | import { QuestModal } from '@/components/modal/QuestModal'; 8 | import { SettingsModal } from '@/components/modal/SettingsModal'; 9 | import { GameResultModal } from '@/components/modal/GameResultModal'; 10 | import { useGameStore } from '@/stores'; 11 | 12 | export default function Home() { 13 | const { isStartGame } = useGameStore(); 14 | 15 | return ( 16 | <> 17 | {!isStartGame ? ( 18 | 19 | ) : ( 20 | 21 | )} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ) 30 | }; -------------------------------------------------------------------------------- /src/components/CustomDock.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import Link from 'next/link'; 3 | import React from 'react'; 4 | import { buttonVariants } from '@/components/ui/button'; 5 | import { Separator } from '@/components/ui/separator'; 6 | import { TooltipWrapper } from '@/components/TooltipWrapper'; 7 | import { cn } from '@/lib/utils'; 8 | import { Dock, DockIcon } from '@/components/magicui/dock'; 9 | 10 | export type IconProps = React.HTMLAttributes; 11 | 12 | const Icons = { 13 | youtube: (props: IconProps) => ( 14 | 22 | youtube 23 | 24 | 25 | ), 26 | github: (props: IconProps) => ( 27 | 28 | 32 | 33 | ), 34 | facebook: (props: IconProps) => ( 35 | 42 | 43 | 44 | ), 45 | discord: (props: IconProps) => ( 46 | 53 | 54 | 55 | ) 56 | }; 57 | 58 | const DATA = { 59 | navbar: [ 60 | { href: 'https://www.facebook.com/guy.suvijak', icon: Icons.facebook, label: 'Facebook' }, 61 | { href: 'https://www.youtube.com/@MeteorVIIx', icon: Icons.youtube, label: 'Youtube' }, 62 | { href: 'https://discord.gg/6KbSzbc999', icon: Icons.discord, label: 'Discord' }, 63 | ], 64 | contact: { 65 | social: { 66 | GitHub: { 67 | name: 'GitHub', 68 | url: 'https://github.com/guysuvijak', 69 | icon: Icons.github, 70 | }, 71 | SourceCode: { 72 | name: 'Source-code', 73 | url: 'https://github.com/guysuvijak/nextjs-minesweeper-game', 74 | icon: Icons.github 75 | } 76 | }, 77 | }, 78 | }; 79 | 80 | export function CustomDock() { 81 | return ( 82 |
83 | 84 | {DATA.navbar.map((item) => ( 85 | 86 | 87 | 97 | 98 | 99 | 100 | 101 | ))} 102 | 103 | 104 | 105 | {Object.entries(DATA.contact.social).map(([name, social]) => ( 106 | 107 | 108 | 118 | 119 | 120 | 121 | 122 | ))} 123 | 124 |
125 | ) 126 | }; -------------------------------------------------------------------------------- /src/components/MainMenu.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import Link from 'next/link'; 3 | import Image from 'next/image'; 4 | import { useEffect } from 'react'; 5 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; 6 | import { Button } from '@/components/ui/button'; 7 | import { useTranslation } from '@/hooks/useTranslation'; 8 | import { Settings } from 'lucide-react'; 9 | import { useTheme } from 'next-themes'; 10 | import { Particles } from '@/components/magicui/particles'; 11 | import { SparklesText } from '@/components/magicui/sparkles-text'; 12 | import { CustomDock } from '@/components/CustomDock'; 13 | import { useCommonStore } from '@/stores'; 14 | import { useWallet } from '@solana/wallet-adapter-react'; 15 | import { Web3ConnectButton } from '@/components/Web3Connect'; 16 | import pkg from '../../package.json'; 17 | 18 | interface PrimaryMenuButtonProps { 19 | title: string; 20 | alt: string; 21 | image: string; 22 | onClick: () => void; 23 | }; 24 | 25 | interface SecondaryMenuButtonProps { 26 | title: string; 27 | alt: string; 28 | image: string; 29 | onClick: () => void; 30 | }; 31 | 32 | const PrimaryMenuButton = ({ title, alt, image, onClick }: PrimaryMenuButtonProps) => ( 33 | 42 | ); 43 | 44 | const SecondaryMenuButton = ({ title, alt, image, onClick }: SecondaryMenuButtonProps) => ( 45 | 54 | ); 55 | 56 | export const MainMenu = () => { 57 | const { t } = useTranslation(); 58 | const { setIsMenuSingleplayerOpen, setIsMenuMultiplayerOpen, setIsMenuAchievementOpen, setIsMenuQuestOpen, setIsMenuSettingOpen } = useCommonStore(); 59 | const wallet = useWallet(); 60 | const { theme } = useTheme(); 61 | const versionGame = pkg.version; 62 | 63 | useEffect(() => { 64 | if (!wallet.connected) { 65 | } 66 | }, [wallet]); 67 | 68 | return ( 69 |
70 | 71 | 72 | 73 | 74 | 75 | 76 | {t('common.game-desc')} 77 | 78 | 79 | 80 | {/* Main Menu - Primary Options */} 81 |
82 | setIsMenuSingleplayerOpen(true)} 84 | title={t('mainmenu.singleplayer.title')} alt={'Singleplayer'} image={'/assets/icons/menu/menu-singleplayer.webp'} 85 | /> 86 | setIsMenuMultiplayerOpen(true)} 88 | title={t('mainmenu.multiplayer.title')} alt={'Multiplayer'} image={'/assets/icons/menu/menu-multiplayer.webp'} 89 | /> 90 | {}} 92 | title={t('mainmenu.store.title')} alt={'Store'} image={'/assets/icons/menu/menu-store.webp'} 93 | /> 94 |
95 | 96 | {/* Secondary Menu Options */} 97 |
98 | setIsMenuAchievementOpen(true)} 100 | title={t('mainmenu.achievement.title')} alt={'Achievement'} image={'/assets/icons/menu/menu-achievement.webp'} 101 | /> 102 | setIsMenuQuestOpen(true)} 104 | title={t('mainmenu.quest.title')} alt={'Quests'} image={'/assets/icons/menu/menu-quest.webp'} 105 | /> 106 |
107 | 108 | {/* Bottom Actions */} 109 |
110 | 111 | 112 | 120 |
121 | 122 | {/* Game Info */} 123 |
124 |

125 | {t('mainmenu.description', { 126 | versionGame: 127 | 128 | {versionGame} 129 | 130 | })} 131 |

132 | 133 |
134 |
135 |
136 | 143 |
144 | ); 145 | }; -------------------------------------------------------------------------------- /src/components/Timer.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useEffect, useRef, useCallback } from 'react'; 3 | import { Badge } from '@/components/ui/badge'; 4 | import { useGameStore } from '@/stores'; 5 | 6 | interface TimerProps { 7 | isRunning: boolean; 8 | onTimeUpdate: (time: number) => void; 9 | }; 10 | 11 | export function Timer({ isRunning, onTimeUpdate }: TimerProps) { 12 | const { time, setTime } = useGameStore(); 13 | const rafRef = useRef(null); 14 | const startTimeRef = useRef(null); 15 | 16 | const updateTime = useCallback(() => { 17 | if (startTimeRef.current !== null) { 18 | const currentTime = performance.now(); 19 | const elapsed = Math.floor( 20 | (currentTime - startTimeRef.current) / 1000 21 | ); 22 | setTime(elapsed); 23 | onTimeUpdate(elapsed); 24 | rafRef.current = requestAnimationFrame(updateTime); 25 | } 26 | }, [onTimeUpdate, setTime]); 27 | 28 | useEffect(() => { 29 | if (isRunning) { 30 | startTimeRef.current = performance.now() - time * 1000; 31 | rafRef.current = requestAnimationFrame(updateTime); 32 | } else if (rafRef.current) { 33 | cancelAnimationFrame(rafRef.current); 34 | } 35 | 36 | return () => { 37 | if (rafRef.current) { 38 | cancelAnimationFrame(rafRef.current); 39 | } 40 | }; 41 | }, [time, isRunning, updateTime]); 42 | 43 | const formatTime = useCallback((seconds: number): string => { 44 | const mins = Math.floor(seconds / 60); 45 | const secs = seconds % 60; 46 | return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; 47 | }, []); 48 | 49 | return ( 50 | 51 | {formatTime(time)} 52 | 53 | ) 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/TooltipWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Tooltip, 3 | TooltipContent, 4 | TooltipProvider, 5 | TooltipTrigger 6 | } from '@/components/ui/tooltip'; 7 | 8 | interface UseTooltipProps { 9 | message: string; 10 | children: React.ReactNode; 11 | position?: 'top' | 'bottom' | 'left' | 'right'; 12 | }; 13 | 14 | const TooltipWrapper = ({ message, children, position = 'top' }: UseTooltipProps) => { 15 | return ( 16 | 17 | 18 | 19 | {children} 20 | 21 | 22 |

{message}

23 |
24 |
25 |
26 | ) 27 | }; 28 | 29 | export { TooltipWrapper }; -------------------------------------------------------------------------------- /src/components/Web3Connect.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import dynamic from 'next/dynamic'; 3 | 4 | const WalletAdapterStyles = () => { 5 | useEffect(() => { 6 | import('@solana/wallet-adapter-react-ui/styles.css').catch(err => console.error('Failed to load wallet styles:', err)); 7 | }, []); 8 | 9 | return null; 10 | }; 11 | 12 | const WalletMultiButton = dynamic( 13 | () => import('@solana/wallet-adapter-react-ui').then(mod => mod.WalletMultiButton), { ssr: false } 14 | ); 15 | 16 | const Web3ConnectButton = () => { 17 | const [ mounted, setMounted ] = useState(false); 18 | 19 | useEffect(() => { 20 | setMounted(true); 21 | }, []); 22 | 23 | return ( 24 | <> 25 | 26 | {mounted && } 27 | 28 | ) 29 | }; 30 | 31 | export { Web3ConnectButton }; -------------------------------------------------------------------------------- /src/components/magicui/confetti.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { 4 | GlobalOptions as ConfettiGlobalOptions, 5 | CreateTypes as ConfettiInstance, 6 | Options as ConfettiOptions, 7 | } from "canvas-confetti"; 8 | import confetti from "canvas-confetti"; 9 | import type { ReactNode } from "react"; 10 | import React, { 11 | createContext, 12 | forwardRef, 13 | useCallback, 14 | useEffect, 15 | useImperativeHandle, 16 | useMemo, 17 | useRef, 18 | } from "react"; 19 | 20 | import { Button, ButtonProps } from "@/components/ui/button"; 21 | 22 | type Api = { 23 | fire: (options?: ConfettiOptions) => void; 24 | }; 25 | 26 | type Props = React.ComponentPropsWithRef<"canvas"> & { 27 | options?: ConfettiOptions; 28 | globalOptions?: ConfettiGlobalOptions; 29 | manualstart?: boolean; 30 | children?: ReactNode; 31 | }; 32 | 33 | export type ConfettiRef = Api | null; 34 | 35 | const ConfettiContext = createContext({} as Api); 36 | 37 | // Define component first 38 | const ConfettiComponent = forwardRef((props, ref) => { 39 | const { 40 | options, 41 | globalOptions = { resize: true, useWorker: true }, 42 | manualstart = false, 43 | children, 44 | ...rest 45 | } = props; 46 | const instanceRef = useRef(null); 47 | 48 | const canvasRef = useCallback( 49 | (node: HTMLCanvasElement) => { 50 | if (node !== null) { 51 | if (instanceRef.current) return; 52 | instanceRef.current = confetti.create(node, { 53 | ...globalOptions, 54 | resize: true, 55 | }); 56 | } else { 57 | if (instanceRef.current) { 58 | instanceRef.current.reset(); 59 | instanceRef.current = null; 60 | } 61 | } 62 | }, 63 | [globalOptions], 64 | ); 65 | 66 | const fire = useCallback( 67 | async (opts = {}) => { 68 | try { 69 | await instanceRef.current?.({ ...options, ...opts }); 70 | } catch (error) { 71 | console.error("Confetti error:", error); 72 | } 73 | }, 74 | [options], 75 | ); 76 | 77 | const api = useMemo( 78 | () => ({ 79 | fire, 80 | }), 81 | [fire], 82 | ); 83 | 84 | useImperativeHandle(ref, () => api, [api]); 85 | 86 | useEffect(() => { 87 | if (!manualstart) { 88 | (async () => { 89 | try { 90 | await fire(); 91 | } catch (error) { 92 | console.error("Confetti effect error:", error); 93 | } 94 | })(); 95 | } 96 | }, [manualstart, fire]); 97 | 98 | return ( 99 | 100 | 101 | {children} 102 | 103 | ); 104 | }); 105 | 106 | // Set display name immediately 107 | ConfettiComponent.displayName = "Confetti"; 108 | 109 | // Export as Confetti 110 | export const Confetti = ConfettiComponent; 111 | 112 | interface ConfettiButtonProps extends ButtonProps { 113 | options?: ConfettiOptions & 114 | ConfettiGlobalOptions & { canvas?: HTMLCanvasElement }; 115 | children?: React.ReactNode; 116 | } 117 | 118 | const ConfettiButtonComponent = ({ 119 | options, 120 | children, 121 | ...props 122 | }: ConfettiButtonProps) => { 123 | const handleClick = async (event: React.MouseEvent) => { 124 | try { 125 | const rect = event.currentTarget.getBoundingClientRect(); 126 | const x = rect.left + rect.width / 2; 127 | const y = rect.top + rect.height / 2; 128 | await confetti({ 129 | ...options, 130 | origin: { 131 | x: x / window.innerWidth, 132 | y: y / window.innerHeight, 133 | }, 134 | }); 135 | } catch (error) { 136 | console.error("Confetti button error:", error); 137 | } 138 | }; 139 | 140 | return ( 141 | 144 | ); 145 | }; 146 | 147 | ConfettiButtonComponent.displayName = "ConfettiButton"; 148 | 149 | export const ConfettiButton = ConfettiButtonComponent; 150 | -------------------------------------------------------------------------------- /src/components/magicui/dock.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | import { 5 | motion, 6 | MotionProps, 7 | MotionValue, 8 | useMotionValue, 9 | useSpring, 10 | useTransform, 11 | } from "motion/react"; 12 | import React, { PropsWithChildren, useRef } from "react"; 13 | 14 | import { cn } from "@/lib/utils"; 15 | 16 | export interface DockProps extends VariantProps { 17 | className?: string; 18 | iconSize?: number; 19 | iconMagnification?: number; 20 | iconDistance?: number; 21 | direction?: "top" | "middle" | "bottom"; 22 | children: React.ReactNode; 23 | } 24 | 25 | const DEFAULT_SIZE = 40; 26 | const DEFAULT_MAGNIFICATION = 60; 27 | const DEFAULT_DISTANCE = 140; 28 | 29 | const dockVariants = cva( 30 | "supports-backdrop-blur:bg-white/10 supports-backdrop-blur:dark:bg-black/10 mx-auto mt-8 flex h-[58px] w-max items-center justify-center gap-2 rounded-2xl border p-2 backdrop-blur-md", 31 | ); 32 | 33 | const Dock = React.forwardRef( 34 | ( 35 | { 36 | className, 37 | children, 38 | iconSize = DEFAULT_SIZE, 39 | iconMagnification = DEFAULT_MAGNIFICATION, 40 | iconDistance = DEFAULT_DISTANCE, 41 | direction = "middle", 42 | ...props 43 | }, 44 | ref, 45 | ) => { 46 | const mouseX = useMotionValue(Infinity); 47 | 48 | const renderChildren = () => { 49 | return React.Children.map(children, (child) => { 50 | if (React.isValidElement(child) && child.type === DockIcon) { 51 | return React.cloneElement(child as React.ReactElement, { 52 | mouseX: mouseX, 53 | size: iconSize, 54 | magnification: iconMagnification, 55 | distance: iconDistance, 56 | }); 57 | } 58 | return child; 59 | }); 60 | }; 61 | 62 | return ( 63 | mouseX.set(e.pageX)} 66 | onMouseLeave={() => mouseX.set(Infinity)} 67 | {...props} 68 | className={cn(dockVariants({ className }), { 69 | "items-start": direction === "top", 70 | "items-center": direction === "middle", 71 | "items-end": direction === "bottom", 72 | })} 73 | > 74 | {renderChildren()} 75 | 76 | ); 77 | }, 78 | ); 79 | 80 | Dock.displayName = "Dock"; 81 | 82 | export interface DockIconProps 83 | extends Omit, "children"> { 84 | size?: number; 85 | magnification?: number; 86 | distance?: number; 87 | mouseX?: MotionValue; 88 | className?: string; 89 | children?: React.ReactNode; 90 | props?: PropsWithChildren; 91 | } 92 | 93 | const DockIcon = ({ 94 | size = DEFAULT_SIZE, 95 | magnification = DEFAULT_MAGNIFICATION, 96 | distance = DEFAULT_DISTANCE, 97 | mouseX, 98 | className, 99 | children, 100 | ...props 101 | }: DockIconProps) => { 102 | const ref = useRef(null); 103 | const padding = Math.max(6, size * 0.2); 104 | const defaultMouseX = useMotionValue(Infinity); 105 | 106 | const distanceCalc = useTransform(mouseX ?? defaultMouseX, (val: number) => { 107 | const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 }; 108 | return val - bounds.x - bounds.width / 2; 109 | }); 110 | 111 | const sizeTransform = useTransform( 112 | distanceCalc, 113 | [-distance, 0, distance], 114 | [size, magnification, size], 115 | ); 116 | 117 | const scaleSize = useSpring(sizeTransform, { 118 | mass: 0.1, 119 | stiffness: 150, 120 | damping: 12, 121 | }); 122 | 123 | return ( 124 | 133 | {children} 134 | 135 | ); 136 | }; 137 | 138 | DockIcon.displayName = "DockIcon"; 139 | 140 | export { Dock, DockIcon, dockVariants }; 141 | -------------------------------------------------------------------------------- /src/components/magicui/particles.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import React, { 5 | ComponentPropsWithoutRef, 6 | useEffect, 7 | useRef, 8 | useState, 9 | } from "react"; 10 | 11 | interface MousePosition { 12 | x: number; 13 | y: number; 14 | } 15 | 16 | function MousePosition(): MousePosition { 17 | const [mousePosition, setMousePosition] = useState({ 18 | x: 0, 19 | y: 0, 20 | }); 21 | 22 | useEffect(() => { 23 | const handleMouseMove = (event: MouseEvent) => { 24 | setMousePosition({ x: event.clientX, y: event.clientY }); 25 | }; 26 | 27 | window.addEventListener("mousemove", handleMouseMove); 28 | 29 | return () => { 30 | window.removeEventListener("mousemove", handleMouseMove); 31 | }; 32 | }, []); 33 | 34 | return mousePosition; 35 | } 36 | 37 | interface ParticlesProps extends ComponentPropsWithoutRef<"div"> { 38 | className?: string; 39 | quantity?: number; 40 | staticity?: number; 41 | ease?: number; 42 | size?: number; 43 | refresh?: boolean; 44 | color?: string; 45 | vx?: number; 46 | vy?: number; 47 | } 48 | 49 | function hexToRgb(hex: string): number[] { 50 | hex = hex.replace("#", ""); 51 | 52 | if (hex.length === 3) { 53 | hex = hex 54 | .split("") 55 | .map((char) => char + char) 56 | .join(""); 57 | } 58 | 59 | const hexInt = parseInt(hex, 16); 60 | const red = (hexInt >> 16) & 255; 61 | const green = (hexInt >> 8) & 255; 62 | const blue = hexInt & 255; 63 | return [red, green, blue]; 64 | } 65 | 66 | type Circle = { 67 | x: number; 68 | y: number; 69 | translateX: number; 70 | translateY: number; 71 | size: number; 72 | alpha: number; 73 | targetAlpha: number; 74 | dx: number; 75 | dy: number; 76 | magnetism: number; 77 | }; 78 | 79 | export const Particles: React.FC = ({ 80 | className = "", 81 | quantity = 100, 82 | staticity = 50, 83 | ease = 50, 84 | size = 0.4, 85 | refresh = false, 86 | color = "#ffffff", 87 | vx = 0, 88 | vy = 0, 89 | ...props 90 | }) => { 91 | const canvasRef = useRef(null); 92 | const canvasContainerRef = useRef(null); 93 | const context = useRef(null); 94 | const circles = useRef([]); 95 | const mousePosition = MousePosition(); 96 | const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); 97 | const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 }); 98 | const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1; 99 | const rafID = useRef(null); 100 | const resizeTimeout = useRef(null); 101 | 102 | useEffect(() => { 103 | if (canvasRef.current) { 104 | context.current = canvasRef.current.getContext("2d"); 105 | } 106 | initCanvas(); 107 | animate(); 108 | 109 | const handleResize = () => { 110 | if (resizeTimeout.current) { 111 | clearTimeout(resizeTimeout.current); 112 | } 113 | resizeTimeout.current = setTimeout(() => { 114 | initCanvas(); 115 | }, 200); 116 | }; 117 | 118 | window.addEventListener("resize", handleResize); 119 | 120 | return () => { 121 | if (rafID.current != null) { 122 | window.cancelAnimationFrame(rafID.current); 123 | } 124 | if (resizeTimeout.current) { 125 | clearTimeout(resizeTimeout.current); 126 | } 127 | window.removeEventListener("resize", handleResize); 128 | }; 129 | // eslint-disable-next-line react-hooks/exhaustive-deps 130 | }, [color]); 131 | 132 | useEffect(() => { 133 | onMouseMove(); 134 | // eslint-disable-next-line react-hooks/exhaustive-deps 135 | }, [mousePosition.x, mousePosition.y]); 136 | 137 | useEffect(() => { 138 | initCanvas(); 139 | // eslint-disable-next-line react-hooks/exhaustive-deps 140 | }, [refresh]); 141 | 142 | const initCanvas = () => { 143 | resizeCanvas(); 144 | drawParticles(); 145 | }; 146 | 147 | const onMouseMove = () => { 148 | if (canvasRef.current) { 149 | const rect = canvasRef.current.getBoundingClientRect(); 150 | const { w, h } = canvasSize.current; 151 | const x = mousePosition.x - rect.left - w / 2; 152 | const y = mousePosition.y - rect.top - h / 2; 153 | const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2; 154 | if (inside) { 155 | mouse.current.x = x; 156 | mouse.current.y = y; 157 | } 158 | } 159 | }; 160 | 161 | const resizeCanvas = () => { 162 | if (canvasContainerRef.current && canvasRef.current && context.current) { 163 | canvasSize.current.w = canvasContainerRef.current.offsetWidth; 164 | canvasSize.current.h = canvasContainerRef.current.offsetHeight; 165 | 166 | canvasRef.current.width = canvasSize.current.w * dpr; 167 | canvasRef.current.height = canvasSize.current.h * dpr; 168 | canvasRef.current.style.width = `${canvasSize.current.w}px`; 169 | canvasRef.current.style.height = `${canvasSize.current.h}px`; 170 | context.current.scale(dpr, dpr); 171 | 172 | // Clear existing particles and create new ones with exact quantity 173 | circles.current = []; 174 | for (let i = 0; i < quantity; i++) { 175 | const circle = circleParams(); 176 | drawCircle(circle); 177 | } 178 | } 179 | }; 180 | 181 | const circleParams = (): Circle => { 182 | const x = Math.floor(Math.random() * canvasSize.current.w); 183 | const y = Math.floor(Math.random() * canvasSize.current.h); 184 | const translateX = 0; 185 | const translateY = 0; 186 | const pSize = Math.floor(Math.random() * 2) + size; 187 | const alpha = 0; 188 | const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1)); 189 | const dx = (Math.random() - 0.5) * 0.1; 190 | const dy = (Math.random() - 0.5) * 0.1; 191 | const magnetism = 0.1 + Math.random() * 4; 192 | return { 193 | x, 194 | y, 195 | translateX, 196 | translateY, 197 | size: pSize, 198 | alpha, 199 | targetAlpha, 200 | dx, 201 | dy, 202 | magnetism, 203 | }; 204 | }; 205 | 206 | const rgb = hexToRgb(color); 207 | 208 | const drawCircle = (circle: Circle, update = false) => { 209 | if (context.current) { 210 | const { x, y, translateX, translateY, size, alpha } = circle; 211 | context.current.translate(translateX, translateY); 212 | context.current.beginPath(); 213 | context.current.arc(x, y, size, 0, 2 * Math.PI); 214 | context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`; 215 | context.current.fill(); 216 | context.current.setTransform(dpr, 0, 0, dpr, 0, 0); 217 | 218 | if (!update) { 219 | circles.current.push(circle); 220 | } 221 | } 222 | }; 223 | 224 | const clearContext = () => { 225 | if (context.current) { 226 | context.current.clearRect( 227 | 0, 228 | 0, 229 | canvasSize.current.w, 230 | canvasSize.current.h, 231 | ); 232 | } 233 | }; 234 | 235 | const drawParticles = () => { 236 | clearContext(); 237 | const particleCount = quantity; 238 | for (let i = 0; i < particleCount; i++) { 239 | const circle = circleParams(); 240 | drawCircle(circle); 241 | } 242 | }; 243 | 244 | const remapValue = ( 245 | value: number, 246 | start1: number, 247 | end1: number, 248 | start2: number, 249 | end2: number, 250 | ): number => { 251 | const remapped = 252 | ((value - start1) * (end2 - start2)) / (end1 - start1) + start2; 253 | return remapped > 0 ? remapped : 0; 254 | }; 255 | 256 | const animate = () => { 257 | clearContext(); 258 | circles.current.forEach((circle: Circle, i: number) => { 259 | // Handle the alpha value 260 | const edge = [ 261 | circle.x + circle.translateX - circle.size, // distance from left edge 262 | canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge 263 | circle.y + circle.translateY - circle.size, // distance from top edge 264 | canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge 265 | ]; 266 | const closestEdge = edge.reduce((a, b) => Math.min(a, b)); 267 | const remapClosestEdge = parseFloat( 268 | remapValue(closestEdge, 0, 20, 0, 1).toFixed(2), 269 | ); 270 | if (remapClosestEdge > 1) { 271 | circle.alpha += 0.02; 272 | if (circle.alpha > circle.targetAlpha) { 273 | circle.alpha = circle.targetAlpha; 274 | } 275 | } else { 276 | circle.alpha = circle.targetAlpha * remapClosestEdge; 277 | } 278 | circle.x += circle.dx + vx; 279 | circle.y += circle.dy + vy; 280 | circle.translateX += 281 | (mouse.current.x / (staticity / circle.magnetism) - circle.translateX) / 282 | ease; 283 | circle.translateY += 284 | (mouse.current.y / (staticity / circle.magnetism) - circle.translateY) / 285 | ease; 286 | 287 | drawCircle(circle, true); 288 | 289 | // circle gets out of the canvas 290 | if ( 291 | circle.x < -circle.size || 292 | circle.x > canvasSize.current.w + circle.size || 293 | circle.y < -circle.size || 294 | circle.y > canvasSize.current.h + circle.size 295 | ) { 296 | // remove the circle from the array 297 | circles.current.splice(i, 1); 298 | // create a new circle 299 | const newCircle = circleParams(); 300 | drawCircle(newCircle); 301 | } 302 | }); 303 | rafID.current = window.requestAnimationFrame(animate); 304 | }; 305 | 306 | return ( 307 | 315 | ); 316 | }; 317 | -------------------------------------------------------------------------------- /src/components/magicui/shimmer-button.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, ComponentPropsWithoutRef } from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface ShimmerButtonProps extends ComponentPropsWithoutRef<"button"> { 6 | shimmerColor?: string; 7 | shimmerSize?: string; 8 | borderRadius?: string; 9 | shimmerDuration?: string; 10 | background?: string; 11 | className?: string; 12 | children?: React.ReactNode; 13 | } 14 | 15 | export const ShimmerButton = React.forwardRef< 16 | HTMLButtonElement, 17 | ShimmerButtonProps 18 | >( 19 | ( 20 | { 21 | shimmerColor = "#ffffff", 22 | shimmerSize = "0.05em", 23 | shimmerDuration = "3s", 24 | borderRadius = "100px", 25 | background = "rgba(0, 0, 0, 1)", 26 | className, 27 | children, 28 | ...props 29 | }, 30 | ref, 31 | ) => { 32 | return ( 33 | 92 | ); 93 | }, 94 | ); 95 | 96 | ShimmerButton.displayName = "ShimmerButton"; 97 | -------------------------------------------------------------------------------- /src/components/magicui/sparkles-text.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { motion } from "motion/react"; 4 | import { CSSProperties, ReactElement, useEffect, useState } from "react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | interface Sparkle { 9 | id: string; 10 | x: string; 11 | y: string; 12 | color: string; 13 | delay: number; 14 | scale: number; 15 | lifespan: number; 16 | } 17 | 18 | const Sparkle: React.FC = ({ id, x, y, color, delay, scale }) => { 19 | return ( 20 | 34 | 38 | 39 | ); 40 | }; 41 | 42 | interface SparklesTextProps { 43 | /** 44 | * @default
45 | * @type ReactElement 46 | * @description 47 | * The component to be rendered as the text 48 | * */ 49 | as?: ReactElement; 50 | 51 | /** 52 | * @default "" 53 | * @type string 54 | * @description 55 | * The className of the text 56 | */ 57 | className?: string; 58 | 59 | /** 60 | * @required 61 | * @type string 62 | * @description 63 | * The text to be displayed 64 | * */ 65 | text: string; 66 | 67 | /** 68 | * @default 10 69 | * @type number 70 | * @description 71 | * The count of sparkles 72 | * */ 73 | sparklesCount?: number; 74 | 75 | /** 76 | * @default "{first: '#9E7AFF', second: '#FE8BBB'}" 77 | * @type string 78 | * @description 79 | * The colors of the sparkles 80 | * */ 81 | colors?: { 82 | first: string; 83 | second: string; 84 | }; 85 | } 86 | 87 | export const SparklesText: React.FC = ({ 88 | text, 89 | colors = { first: "#9E7AFF", second: "#FE8BBB" }, 90 | className, 91 | sparklesCount = 10, 92 | ...props 93 | }) => { 94 | const [sparkles, setSparkles] = useState([]); 95 | 96 | useEffect(() => { 97 | const generateStar = (): Sparkle => { 98 | const starX = `${Math.random() * 100}%`; 99 | const starY = `${Math.random() * 100}%`; 100 | const color = Math.random() > 0.5 ? colors.first : colors.second; 101 | const delay = Math.random() * 2; 102 | const scale = Math.random() * 1 + 0.3; 103 | const lifespan = Math.random() * 10 + 5; 104 | const id = `${starX}-${starY}-${Date.now()}`; 105 | return { id, x: starX, y: starY, color, delay, scale, lifespan }; 106 | }; 107 | 108 | const initializeStars = () => { 109 | const newSparkles = Array.from({ length: sparklesCount }, generateStar); 110 | setSparkles(newSparkles); 111 | }; 112 | 113 | const updateStars = () => { 114 | setSparkles((currentSparkles) => 115 | currentSparkles.map((star) => { 116 | if (star.lifespan <= 0) { 117 | return generateStar(); 118 | } else { 119 | return { ...star, lifespan: star.lifespan - 0.1 }; 120 | } 121 | }), 122 | ); 123 | }; 124 | 125 | initializeStars(); 126 | const interval = setInterval(updateStars, 100); 127 | 128 | return () => clearInterval(interval); 129 | }, [colors.first, colors.second, sparklesCount]); 130 | 131 | return ( 132 |
142 | 143 | {sparkles.map((sparkle) => ( 144 | 145 | ))} 146 | {text} 147 | 148 |
149 | ); 150 | }; 151 | -------------------------------------------------------------------------------- /src/components/mainmenu/AchievementCard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useTranslation } from '@/hooks/useTranslation'; 3 | import { Card, CardContent } from '@/components/ui/card'; 4 | import { Badge } from '@/components/ui/badge'; 5 | import { cn } from '@/lib/utils'; 6 | 7 | interface AchievementCardProps { 8 | title: string; 9 | description: string; 10 | collected: boolean; 11 | dateCollected?: string; 12 | icon: string; 13 | }; 14 | 15 | const AchievementCard = ({ title, description, collected, dateCollected, icon }: AchievementCardProps) => { 16 | const { t } = useTranslation(); 17 | 18 | return ( 19 | 23 | 24 |
28 | {icon} 29 |
30 |
31 |

32 | {title} 33 | {collected && {t('achievement.complete')}} 34 |

35 |

{description}

36 | {collected && dateCollected && ( 37 |

38 | {t('achievement.achieved', { date: new Date(dateCollected).toLocaleDateString() })} 39 |

40 | )} 41 |
42 |
43 |
44 | ); 45 | }; 46 | 47 | export { AchievementCard }; -------------------------------------------------------------------------------- /src/components/mainmenu/QuestCard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useTranslation } from '@/hooks/useTranslation'; 3 | import { Card, CardContent } from '@/components/ui/card'; 4 | import { Badge } from '@/components/ui/badge'; 5 | import { cn } from '@/lib/utils'; 6 | 7 | interface QuestCardProps { 8 | title: string; 9 | description: string; 10 | progress: number; 11 | total: number; 12 | reward: string; 13 | completed?: boolean; 14 | }; 15 | 16 | const QuestCard = ({ title, description, progress, total, reward, completed = false }: QuestCardProps) => { 17 | const { t } = useTranslation(); 18 | const progressPercent = (progress / total) * 100; 19 | 20 | return ( 21 | 25 | 26 |
27 |

{title}

28 | {reward} 29 |
30 |

{description}

31 |
32 |
33 | {progress} / {total} 34 | {completed && {t('quest.complete')}} 35 |
36 |
37 |
44 |
45 |
46 | 47 | 48 | ); 49 | }; 50 | 51 | export { QuestCard }; -------------------------------------------------------------------------------- /src/components/modal/AchievementModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; 3 | import { useTranslation } from '@/hooks/useTranslation'; 4 | import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; 5 | import { MOCK_ACHIEVEMENT_DATA } from '@/configs/mock'; 6 | import { AchievementCard } from '@/components/mainmenu/AchievementCard'; 7 | import { useCommonStore } from '@/stores'; 8 | 9 | export const AchievementModal = () => { 10 | const { t } = useTranslation(); 11 | const { isMenuAchievementOpen, setIsMenuAchievementOpen } = useCommonStore(); 12 | 13 | return ( 14 | 15 | 16 | 17 | {t('mainmenu.achievement.title')} 18 | 19 | {t('mainmenu.achievement.description')} 20 | 21 | 22 | 23 | 24 | {t('mainmenu.achievement.menu-all')} 25 | {t('mainmenu.achievement.menu-collected')} 26 | {t('mainmenu.achievement.menu-not-collected')} 27 | 28 | 29 |
30 | {/* Achievement Items All Collected */} 31 | {MOCK_ACHIEVEMENT_DATA.map((achievement, index) => ( 32 | 40 | ))} 41 |
42 |
43 | 44 |
45 | {/* Achievement Items Not Collected */} 46 | {MOCK_ACHIEVEMENT_DATA.filter((achievement) => achievement.collected).map((achievement, index) => ( 47 | 55 | ))} 56 |
57 |
58 | 59 |
60 | {/* Achievement Items */} 61 | {MOCK_ACHIEVEMENT_DATA.filter((achievement) => !achievement.collected).map((achievement, index) => ( 62 | 70 | ))} 71 |
72 |
73 |
74 |
75 |
76 | ) 77 | }; -------------------------------------------------------------------------------- /src/components/modal/MultiplayerModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; 3 | import { Button } from '@/components/ui/button'; 4 | import { Badge } from '@/components/ui/badge'; 5 | import { ChevronRight, Trophy, Users } from 'lucide-react'; 6 | import { useTranslation } from '@/hooks/useTranslation'; 7 | import { useCommonStore } from '@/stores'; 8 | 9 | export const MultiplayerModal = () => { 10 | const { t } = useTranslation(); 11 | const { isMenuMultiplayerOpen, setIsMenuMultiplayerOpen } = useCommonStore(); 12 | 13 | return ( 14 | 15 | 16 | 17 | {t('mainmenu.multiplayer.title')} 18 | 19 | {t('mainmenu.multiplayer.description')} 20 | 21 | 22 |
23 | 39 | 40 | 56 |
57 |
58 |
59 | ) 60 | }; -------------------------------------------------------------------------------- /src/components/modal/QuestModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; 3 | import { useTranslation } from '@/hooks/useTranslation'; 4 | import { MOCK_QUEST_DATA } from '@/configs/mock'; 5 | import { QuestCard } from '@/components/mainmenu/QuestCard'; 6 | import { useCommonStore } from '@/stores'; 7 | 8 | export const QuestModal = () => { 9 | const { t } = useTranslation(); 10 | const { isMenuQuestOpen, setIsMenuQuestOpen } = useCommonStore(); 11 | 12 | return ( 13 | 14 | 15 | 16 | {t('mainmenu.quest.title')} 17 | 18 | {t('mainmenu.quest.description')} 19 | 20 | 21 |
22 |
23 | {MOCK_QUEST_DATA.map((quest, index) => ( 24 | 33 | ))} 34 |
35 |
36 |
37 | {t('mainmenu.quest.reset')} 38 |
39 |
40 |
41 | ) 42 | }; -------------------------------------------------------------------------------- /src/components/modal/SettingsModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useMemo } from 'react'; 3 | import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; 4 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 5 | import { Button } from '@/components/ui/button'; 6 | import { Input } from '@/components/ui/input'; 7 | import { Moon, Sun } from 'lucide-react'; 8 | import { useTranslation } from '@/hooks/useTranslation'; 9 | import { Label } from '@/components/ui/label'; 10 | import { useTheme } from 'next-themes'; 11 | import { LANGUAGE_OPTIONS, FLAG_OPTIONS, BOMB_OPTIONS, NUMBER_OPTIONS } from '@/configs'; 12 | import { useCommonStore, useLanguageStore, useSettingStore } from '@/stores'; 13 | 14 | interface Option { 15 | value: string; 16 | label?: string; 17 | icon?: React.ElementType; 18 | svgIcon?: string; 19 | }; 20 | 21 | interface SelectFieldProps { 22 | id: string; 23 | label: string; 24 | color?: string; 25 | value: string; 26 | options: Option[]; 27 | onChange: (value: string) => void; 28 | }; 29 | 30 | const SelectField: React.FC = ({ id, label, color, value, options, onChange }) => { 31 | const { t } = useTranslation(); 32 | 33 | return ( 34 | <> 35 | 36 | 57 | 58 | ); 59 | }; 60 | 61 | export const SettingsModal = () => { 62 | const { t } = useTranslation(); 63 | const { lang, setLang } = useLanguageStore(); 64 | const { theme, setTheme } = useTheme(); 65 | const { isMenuSettingOpen, setIsMenuSettingOpen } = useCommonStore(); 66 | const { 67 | flagIcon, setFlagIcon, 68 | flagColor, setFlagColor, 69 | bombIcon, setBombIcon, 70 | numberStyle, setNumberStyle 71 | } = useSettingStore(); 72 | 73 | const Icon = useMemo(() => (theme === 'dark' ? Sun : Moon), [theme]); 74 | 75 | const handleFlagColorChange = (e: React.ChangeEvent) => { 76 | setFlagColor(e.target.value); 77 | }; 78 | 79 | return ( 80 | 81 | 82 | 83 | {t('settings.title')} 84 | 85 | {t('settings.description')} 86 | 87 | 88 |
89 |
90 | {/* Theme Toggle */} 91 | 92 | 102 | 103 | {/* Language Selection */} 104 | 111 | 112 | {/* Flag Style Selection */} 113 | 121 | 122 | {/* Flag Color Picker */} 123 | 124 |
125 | 133 | 140 |
141 | 142 | {/* Bomb Style Selection */} 143 | 150 | 151 | {/* Number Style Selection */} 152 | 159 |
160 |
161 |
162 | 165 |
166 |
167 |
168 | ) 169 | }; -------------------------------------------------------------------------------- /src/components/modal/SingleplayerModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import Image from 'next/image'; 3 | import { Button } from '@/components/ui/button'; 4 | import { Badge } from '@/components/ui/badge'; 5 | import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; 6 | import { useTranslation } from '@/hooks/useTranslation'; 7 | import { Bomb } from 'lucide-react'; 8 | import { useCommonStore, useGameStore } from '@/stores'; 9 | import { cn } from '@/lib/utils'; 10 | import { DIFFICULTY_DATA } from '@/configs'; 11 | 12 | export const SingleplayerModal = () => { 13 | const { t } = useTranslation(); 14 | const { isMenuSingleplayerOpen, setIsMenuSingleplayerOpen } = useCommonStore(); 15 | const { difficulty, setDifficulty, setIsStartGame } = useGameStore(); 16 | 17 | const handleGameStart = () => { 18 | setIsStartGame(true); 19 | }; 20 | 21 | return ( 22 | 23 | 24 | 25 | {t('mainmenu.singleplayer.title')} 26 | 27 | {t('mainmenu.singleplayer.description')} 28 | 29 | 30 |
31 | {Object.entries(DIFFICULTY_DATA).map(([key, data]) => ( 32 | 70 | ))} 71 |
72 |
73 |
74 | ) 75 | }; -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |
61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogTrigger, 116 | DialogClose, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 74 | 75 | )) 76 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 77 | 78 | const DropdownMenuItem = React.forwardRef< 79 | React.ElementRef, 80 | React.ComponentPropsWithoutRef & { 81 | inset?: boolean 82 | } 83 | >(({ className, inset, ...props }, ref) => ( 84 | svg]:size-4 [&>svg]:shrink-0", 88 | inset && "pl-8", 89 | className 90 | )} 91 | {...props} 92 | /> 93 | )) 94 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 95 | 96 | const DropdownMenuCheckboxItem = React.forwardRef< 97 | React.ElementRef, 98 | React.ComponentPropsWithoutRef 99 | >(({ className, children, checked, ...props }, ref) => ( 100 | 109 | 110 | 111 | 112 | 113 | 114 | {children} 115 | 116 | )) 117 | DropdownMenuCheckboxItem.displayName = 118 | DropdownMenuPrimitive.CheckboxItem.displayName 119 | 120 | const DropdownMenuRadioItem = React.forwardRef< 121 | React.ElementRef, 122 | React.ComponentPropsWithoutRef 123 | >(({ className, children, ...props }, ref) => ( 124 | 132 | 133 | 134 | 135 | 136 | 137 | {children} 138 | 139 | )) 140 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 141 | 142 | const DropdownMenuLabel = React.forwardRef< 143 | React.ElementRef, 144 | React.ComponentPropsWithoutRef & { 145 | inset?: boolean 146 | } 147 | >(({ className, inset, ...props }, ref) => ( 148 | 157 | )) 158 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 159 | 160 | const DropdownMenuSeparator = React.forwardRef< 161 | React.ElementRef, 162 | React.ComponentPropsWithoutRef 163 | >(({ className, ...props }, ref) => ( 164 | 169 | )) 170 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 171 | 172 | const DropdownMenuShortcut = ({ 173 | className, 174 | ...props 175 | }: React.HTMLAttributes) => { 176 | return ( 177 | 181 | ) 182 | } 183 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 184 | 185 | export { 186 | DropdownMenu, 187 | DropdownMenuTrigger, 188 | DropdownMenuContent, 189 | DropdownMenuItem, 190 | DropdownMenuCheckboxItem, 191 | DropdownMenuRadioItem, 192 | DropdownMenuLabel, 193 | DropdownMenuSeparator, 194 | DropdownMenuShortcut, 195 | DropdownMenuGroup, 196 | DropdownMenuPortal, 197 | DropdownMenuSub, 198 | DropdownMenuSubContent, 199 | DropdownMenuSubTrigger, 200 | DropdownMenuRadioGroup, 201 | } 202 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SelectPrimitive from "@radix-ui/react-select" 5 | import { Check, ChevronDown, ChevronUp } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Select = SelectPrimitive.Root 10 | 11 | const SelectGroup = SelectPrimitive.Group 12 | 13 | const SelectValue = SelectPrimitive.Value 14 | 15 | const SelectTrigger = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, children, ...props }, ref) => ( 19 | span]:line-clamp-1", 23 | className 24 | )} 25 | {...props} 26 | > 27 | {children} 28 | 29 | 30 | 31 | 32 | )) 33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 34 | 35 | const SelectScrollUpButton = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | 48 | 49 | )) 50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 51 | 52 | const SelectScrollDownButton = React.forwardRef< 53 | React.ElementRef, 54 | React.ComponentPropsWithoutRef 55 | >(({ className, ...props }, ref) => ( 56 | 64 | 65 | 66 | )) 67 | SelectScrollDownButton.displayName = 68 | SelectPrimitive.ScrollDownButton.displayName 69 | 70 | const SelectContent = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >(({ className, children, position = "popper", ...props }, ref) => ( 74 | 75 | 86 | 87 | 94 | {children} 95 | 96 | 97 | 98 | 99 | )) 100 | SelectContent.displayName = SelectPrimitive.Content.displayName 101 | 102 | const SelectLabel = React.forwardRef< 103 | React.ElementRef, 104 | React.ComponentPropsWithoutRef 105 | >(({ className, ...props }, ref) => ( 106 | 111 | )) 112 | SelectLabel.displayName = SelectPrimitive.Label.displayName 113 | 114 | const SelectItem = React.forwardRef< 115 | React.ElementRef, 116 | React.ComponentPropsWithoutRef 117 | >(({ className, children, ...props }, ref) => ( 118 | 126 | 127 | 128 | 129 | 130 | 131 | {children} 132 | 133 | )) 134 | SelectItem.displayName = SelectPrimitive.Item.displayName 135 | 136 | const SelectSeparator = React.forwardRef< 137 | React.ElementRef, 138 | React.ComponentPropsWithoutRef 139 | >(({ className, ...props }, ref) => ( 140 | 145 | )) 146 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 147 | 148 | export { 149 | Select, 150 | SelectGroup, 151 | SelectValue, 152 | SelectTrigger, 153 | SelectContent, 154 | SelectLabel, 155 | SelectItem, 156 | SelectSeparator, 157 | SelectScrollUpButton, 158 | SelectScrollDownButton, 159 | } 160 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Tabs({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 18 | ) 19 | } 20 | 21 | function TabsList({ 22 | className, 23 | ...props 24 | }: React.ComponentProps) { 25 | return ( 26 | 34 | ) 35 | } 36 | 37 | function TabsTrigger({ 38 | className, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 50 | ) 51 | } 52 | 53 | function TabsContent({ 54 | className, 55 | ...props 56 | }: React.ComponentProps) { 57 | return ( 58 | 63 | ) 64 | } 65 | 66 | export { Tabs, TabsList, TabsTrigger, TabsContent } 67 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 19 | 28 | 29 | )) 30 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 31 | 32 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 33 | -------------------------------------------------------------------------------- /src/configs/game.tsx: -------------------------------------------------------------------------------- 1 | import { FLAG_OPTIONS, BOMB_OPTIONS } from '@/configs/settings'; 2 | import { FlagStyle, BombStyle, NumberStyle } from '@/types'; 3 | 4 | export const DIFFICULTY_DATA = { 5 | easy: { mines: 10, size: '9x9', rows: 9, cols: 9, image: '/assets/icons/mode/mode-easy.webp' }, 6 | medium: { mines: 40, size: '16x16', rows: 16, cols: 16, image: '/assets/icons/mode/mode-medium.webp' }, 7 | hard: { mines: 99, size: '16x30', rows: 16, cols: 30, image: '/assets/icons/mode/mode-hard.webp' } 8 | }; 9 | 10 | export const SCORE_CONFIG = { 11 | baseScore: 1000, 12 | maxTime: 300, 13 | multipliers: { 14 | easy: 1, 15 | medium: 2, 16 | hard: 3 17 | }, 18 | flagBonus: 1.5 19 | }; 20 | 21 | const NUMBER_STYLES = { 22 | roman: ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII'], 23 | thai: ['๑', '๒', '๓', '๔', '๕', '๖', '๗', '๘'], 24 | abc: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'], 25 | question: ['?', '?', '?', '?', '?', '?', '?', '?'] 26 | }; 27 | 28 | export const getFlagIcon = (style: FlagStyle, color: string) => { 29 | const option = FLAG_OPTIONS.find(opt => opt.value === style); 30 | const Icon = option?.icon || FLAG_OPTIONS[0].icon; 31 | return ; 32 | }; 33 | 34 | export const getBombIcon = (style: BombStyle) => { 35 | const option = BOMB_OPTIONS.find(opt => opt.value === style); 36 | const Icon = option?.icon || BOMB_OPTIONS[0].icon; 37 | return ; 38 | }; 39 | 40 | export const getNumberDisplay = (number: number, style: NumberStyle) => { 41 | if (number === 0) return null; 42 | 43 | if (style === 'default') return number; 44 | 45 | return NUMBER_STYLES[style]?.[number - 1] || number; 46 | }; -------------------------------------------------------------------------------- /src/configs/index.ts: -------------------------------------------------------------------------------- 1 | import { VIEWPORT, METADATA } from '@/configs/metadata'; 2 | import { DIFFICULTY_DATA, SCORE_CONFIG, getFlagIcon, getBombIcon, getNumberDisplay } from '@/configs/game'; 3 | import { LANGUAGE_OPTIONS, FLAG_OPTIONS, BOMB_OPTIONS, NUMBER_OPTIONS } from '@/configs/settings'; 4 | 5 | export { 6 | VIEWPORT, METADATA, 7 | DIFFICULTY_DATA, SCORE_CONFIG, getFlagIcon, getBombIcon, getNumberDisplay, 8 | LANGUAGE_OPTIONS, FLAG_OPTIONS, BOMB_OPTIONS, NUMBER_OPTIONS 9 | }; -------------------------------------------------------------------------------- /src/configs/metadata.ts: -------------------------------------------------------------------------------- 1 | import type { Viewport, Metadata } from 'next'; 2 | 3 | export const VIEWPORT: Viewport = { 4 | width: 'device-width', 5 | initialScale: 1, 6 | maximumScale: 5, 7 | themeColor: '#000000' 8 | } 9 | 10 | export const METADATA: Metadata = { 11 | manifest: '/manifest.json', 12 | title: 'Minesweeper', 13 | description: 'A simple implementation of the classic Minesweeper game built with Next.js. This project showcases the use of React components and modern front-end development techniques. Players can enjoy the Minesweeper game experience directly in their browser, with a clean and responsive UI.', 14 | openGraph: { 15 | title: 'Minesweeper', 16 | description: 'A simple implementation of the classic Minesweeper game built with Next.js. This project showcases the use of React components and modern front-end development techniques. Players can enjoy the Minesweeper game experience directly in their browser, with a clean and responsive UI.', 17 | url: 'https://nextjs-minesweeper-game.vercel.app/', 18 | siteName: 'Minesweeper', 19 | images: [ 20 | { 21 | url: 'https://nextjs-minesweeper-game.vercel.app/metadata/manifest.webp', 22 | width: 1200, 23 | height: 630 24 | } 25 | ] 26 | }, 27 | keywords: ['Minesweeper', 'nextjs', 'game-website', 'meteorviix'], 28 | authors: [ 29 | { name: 'Minesweeper' }, 30 | { 31 | name: 'Minesweeper', 32 | url: 'https://nextjs-minesweeper-game.vercel.app/', 33 | }, 34 | ], 35 | icons: [ 36 | { rel: 'apple-touch-icon', url: 'icon/icon-128x128.png' }, 37 | { rel: 'icon', url: 'icon/icon-128x128.png' }, 38 | ], 39 | appleWebApp: { 40 | capable: true, 41 | statusBarStyle: 'default', 42 | title: 'Minesweeper' 43 | }, 44 | applicationName: 'Minesweeper', 45 | formatDetection: { 46 | telephone: false 47 | } 48 | }; -------------------------------------------------------------------------------- /src/configs/mock.ts: -------------------------------------------------------------------------------- 1 | export const MOCK_QUEST_DATA = [ 2 | { 3 | "id": 1, 4 | "title": "Quick Victory", 5 | "description": "Win a game on any difficulty in under 60 seconds", 6 | "progress": 0, 7 | "total": 1, 8 | "reward": "50 points", 9 | "completed": false 10 | }, 11 | { 12 | "id": 2, 13 | "title": "Mine Sweeper", 14 | "description": "Win 3 games on medium difficulty or higher", 15 | "progress": 2, 16 | "total": 3, 17 | "reward": "100 points", 18 | "completed": false 19 | }, 20 | { 21 | "id": 3, 22 | "title": "Perfect Game", 23 | "description": "Complete a game without using any flags", 24 | "progress": 1, 25 | "total": 1, 26 | "reward": "150 points", 27 | "completed": true 28 | } 29 | ]; 30 | 31 | export const MOCK_ACHIEVEMENT_DATA = [ 32 | { 33 | "title": "First Win", 34 | "description": "Win your first game of Minesweeper", 35 | "collected": true, 36 | "dateCollected": "2023-09-15", 37 | "icon": "🏆" 38 | }, 39 | { 40 | "title": "Speed Demon", 41 | "description": "Complete a medium difficulty game in under 30 seconds", 42 | "collected": false, 43 | "dateCollected": null, 44 | "icon": "⚡" 45 | }, 46 | { 47 | "title": "Bomb Squad", 48 | "description": "Win 10 games on hard difficulty", 49 | "collected": true, 50 | "dateCollected": "2024-01-20", 51 | "icon": "💣" 52 | }, 53 | { 54 | "title": "Undefeated", 55 | "description": "Win 5 games in a row without losing", 56 | "collected": false, 57 | "dateCollected": null, 58 | "icon": "🔥" 59 | } 60 | ]; -------------------------------------------------------------------------------- /src/configs/settings.ts: -------------------------------------------------------------------------------- 1 | import { Language, FlagStyle, BombStyle, NumberStyle } from '@/types'; 2 | import { 3 | Pyramid, Radar, Sparkles, Sigma, Flag, 4 | Skull, Flame, FlameKindling, Ghost, Bomb 5 | } from 'lucide-react'; 6 | 7 | export const LANGUAGE_OPTIONS: { value: Language; label: string; svgIcon: string; }[] = [ 8 | { value: 'en', label: 'settings.language.en', svgIcon: '' }, 9 | { value: 'th', label: 'settings.language.th', svgIcon: '' }, 10 | { value: 'jp', label: 'settings.language.jp', svgIcon: '' }, 11 | { value: 'vi', label: 'settings.language.vi', svgIcon: '' }, 12 | { value: 'zh', label: 'settings.language.zh', svgIcon: '' } 13 | ]; 14 | 15 | export const FLAG_OPTIONS: { value: FlagStyle; icon: typeof Flag; }[] = [ 16 | { value: 'default', icon: Flag }, 17 | { value: 'pyramid', icon: Pyramid }, 18 | { value: 'radar', icon: Radar }, 19 | { value: 'sparkles', icon: Sparkles }, 20 | { value: 'sigma', icon: Sigma } 21 | ]; 22 | 23 | export const BOMB_OPTIONS: { value: BombStyle; icon: typeof Bomb; }[] = [ 24 | { value: 'default', icon: Bomb }, 25 | { value: 'skull', icon: Skull }, 26 | { value: 'fire', icon: Flame }, 27 | { value: 'flame', icon: FlameKindling }, 28 | { value: 'ghost', icon: Ghost } 29 | ]; 30 | 31 | export const NUMBER_OPTIONS: { value: NumberStyle; label: string; }[] = [ 32 | { value: 'default', label: 'settings.number.default' }, 33 | { value: 'roman', label: 'settings.number.roman' }, 34 | { value: 'thai', label: 'settings.number.thai' }, 35 | { value: 'abc', label: 'settings.number.abc' }, 36 | { value: 'question', label: 'settings.number.question' } 37 | ]; -------------------------------------------------------------------------------- /src/hooks/useTranslation.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useState, useEffect, useCallback, ReactNode } from 'react'; 3 | import { useLanguageStore } from '@/stores/languageStore'; 4 | import { TranslationValue, Variables, Translation } from '@/types'; 5 | 6 | const translationCache: { [key: string]: Translation } = {}; 7 | 8 | export const useTranslation = () => { 9 | const { lang } = useLanguageStore(); 10 | const [ dict, setDict ] = useState({}); 11 | 12 | const loadTranslations = useCallback(async () => { 13 | try { 14 | if (translationCache[lang]) { 15 | setDict(translationCache[lang]); 16 | return; 17 | } 18 | 19 | const translation = await import(`@/locales/${lang}.json`); 20 | translationCache[lang] = translation.default; 21 | setDict(translation.default); 22 | } catch (e) { 23 | console.error(`Failed to load ${lang} translation:`, e); 24 | } 25 | }, [lang]); 26 | 27 | useEffect(() => { 28 | loadTranslations(); 29 | }, [loadTranslations]); 30 | 31 | const getTranslation = useCallback((key: string, variables?: Variables): string | ReactNode[] => { 32 | try { 33 | const result = key.split('.').reduce((obj, k) => { 34 | if (typeof obj === 'object') { 35 | return obj[k] || key; 36 | } 37 | return key; 38 | }, dict); 39 | 40 | if (typeof result === 'string' && variables) { 41 | const parts = result.split(/({[^}]+})/g); 42 | return parts.map((part, index) => { 43 | const matches = part.match(/^{([^}]+)}$/); 44 | if (matches && variables[matches[1]] !== undefined) { 45 | const value = variables[matches[1]]; 46 | return {value}; 47 | } 48 | return part; 49 | }); 50 | } 51 | 52 | return typeof result === 'string' ? result : key; 53 | } catch { 54 | return key; 55 | } 56 | }, [dict]); 57 | 58 | function t(key: string): string; 59 | function t(key: string, variables: Variables): ReactNode[]; 60 | function t(key: string, variables?: Variables): string | ReactNode[] { 61 | return getTranslation(key, variables); 62 | } 63 | 64 | return { t, lang }; 65 | }; -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | }; -------------------------------------------------------------------------------- /src/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "lang": "en", 4 | "game-title": "Minesweeper", 5 | "game-desc": "Digging holes for fun!", 6 | "loading": "Loading..." 7 | }, 8 | "not-found": { 9 | "title": "Not Found", 10 | "description": "Please return to the home page.", 11 | "loading": "Loading...", 12 | "button": "Back" 13 | }, 14 | "difficulty": { 15 | "easy": "Easy", 16 | "medium": "Medium", 17 | "hard": "Hard" 18 | }, 19 | "gameStats": { 20 | "timePlayed": "Time Played", 21 | "flagsPlaced": "Flags Placed", 22 | "difficulty": "Difficulty" 23 | }, 24 | "gameresult": { 25 | "description": "Minesweeper is fun!", 26 | "win": "You Win!", 27 | "lose": "Game Over!", 28 | "time": "Time", 29 | "score": "Score", 30 | "difficulty": "Difficulty", 31 | "flag": "Flag Placed", 32 | "share-button": "Share", 33 | "again-button": "Play again" 34 | }, 35 | "mainmenu": { 36 | "description": "Create by MeteorVIIx | Version {versionGame}", 37 | "settings": "Settings", 38 | "singleplayer": { 39 | "title": "Singleplayer", 40 | "description": "Select difficulty", 41 | "easy": "Easy", 42 | "medium": "Medium", 43 | "hard": "Hard" 44 | }, 45 | "multiplayer": { 46 | "title": "Multiplayer", 47 | "description": "Select a competition", 48 | "casual-title": "Casual Matchmaking", 49 | "casual-desc": "Play for fun with no rank changes", 50 | "rank-title": "Ranked Matchmaking", 51 | "rank-desc": "Compete to climb the leaderboard" 52 | }, 53 | "store": { 54 | "title": "Store" 55 | }, 56 | "achievement": { 57 | "title": "Achievement", 58 | "description": "All achievements are shown here", 59 | "menu-all": "All", 60 | "menu-collected":"Collected", 61 | "menu-not-collected": "Not Collected" 62 | }, 63 | "quest": { 64 | "title": "Quests", 65 | "description": "Complete daily quests to earn rewards. Resets at midnight.", 66 | "reset": "Next reset at 00:00 (GMT+7)" 67 | } 68 | }, 69 | "achievement": { 70 | "complete": "Completed", 71 | "achieved": "Achieved on: {date}" 72 | }, 73 | "quest": { 74 | "complete": "Completed" 75 | }, 76 | "game": { 77 | "mine": "Mines", 78 | "reset-tooltip": "Reset", 79 | "win": "You Win!", 80 | "lose": "Game Over!", 81 | "flag-mode": "Flag Mode", 82 | "dig-mode": "Dig Mode" 83 | }, 84 | "settings": { 85 | "title": "Settings", 86 | "description": "Freely customized", 87 | "done-button": "Done", 88 | "theme": { 89 | "title": "Theme", 90 | "system": "System", 91 | "light": "Light", 92 | "dark": "Dark" 93 | }, 94 | "language": { 95 | "title": "Languages", 96 | "th": "Thai", 97 | "en": "English", 98 | "jp": "Japanese", 99 | "vi": "Vietnamese", 100 | "zh": "Chinese" 101 | }, 102 | "flag": { 103 | "title": "Flag" 104 | }, 105 | "flag-color": { 106 | "title": "Flag Color" 107 | }, 108 | "bomb": { 109 | "title": "Bomb" 110 | }, 111 | "number": { 112 | "title": "Number", 113 | "default": "(1) Normal", 114 | "roman": "(I) Roman", 115 | "thai": "(๑) Thai", 116 | "abc": "(A) ABC", 117 | "question": "(?) Question" 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /src/locales/jp.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "lang": "jp", 4 | "game-title": "マインスイーパー", 5 | "game-desc": "楽しく穴掘り!", 6 | "loading": "読み込み中..." 7 | }, 8 | "not-found": { 9 | "title": "見つかりませんでした", 10 | "description": "ホームページに戻ってください。", 11 | "loading": "読み込み中...", 12 | "button": "戻る" 13 | }, 14 | "difficulty": { 15 | "easy": "簡単", 16 | "medium": "普通", 17 | "hard": "難しい" 18 | }, 19 | "gameStats": { 20 | "timePlayed": "プレイ時間", 21 | "flagsPlaced": "配置した旗", 22 | "difficulty": "難易度" 23 | }, 24 | "gameresult": { 25 | "description": "Minesweeper は面白い!", 26 | "win": "あなたの勝ち!", 27 | "lose": "ゲームオーバー!", 28 | "time": "時間", 29 | "score": "スコア", 30 | "difficulty": "難易度", 31 | "flag": "配置した旗", 32 | "share-button": "共有", 33 | "again-button": "遊ぶ" 34 | }, 35 | "mainmenu": { 36 | "description": "作成者: MeteorVIIx | バージョン {versionGame}", 37 | "settings": "設定", 38 | "singleplayer": { 39 | "title": "シングルプレイヤー", 40 | "description": "難易度を選択", 41 | "easy": "簡単", 42 | "medium": "普通", 43 | "hard": "難しい" 44 | }, 45 | "multiplayer": { 46 | "title": "マルチプレイヤー", 47 | "description": "コンテストを選択", 48 | "casual-title": "カジュアル マッチメイキング", 49 | "casual-desc": "ランク変更なしで楽しくプレイ", 50 | "rank-title": "ランク マッチメイキング", 51 | "rank-desc": "ランキング上位を目指して競い合う" 52 | }, 53 | "store": { 54 | "title": "ストア" 55 | }, 56 | "achievement": { 57 | "title": "実績", 58 | "description": "すべての実績がここに表示されます", 59 | "menu-all": "すべて", 60 | "menu-collected": "収集済み", 61 | "menu-not-collected": "収集されていません" 62 | }, 63 | "quest": { 64 | "title": "クエスト", 65 | "description": "毎日のクエストを完了して報酬を獲得します。真夜中にリセットされます。", 66 | "reset": "次回のリセットは00:00(GMT+7)" 67 | } 68 | }, 69 | "achievement": { 70 | "complete": "完了しました", 71 | "achieved": "達成日: {date}" 72 | }, 73 | "quest": { 74 | "complete": "完了" 75 | }, 76 | "game": { 77 | "mine": "地雷", 78 | "reset-tooltip": "リセット", 79 | "win": "あなたの勝ち!", 80 | "lose": "ゲームオーバー!", 81 | "flag-mode": "フラグモード", 82 | "dig-mode": "ディグモード" 83 | }, 84 | "settings": { 85 | "title": "設定", 86 | "description": "自由にカスタマイズ", 87 | "done-button": "近い", 88 | "theme": { 89 | "title": "テーマ", 90 | "system": "システム", 91 | "light": "ライト", 92 | "dark": "ダーク" 93 | }, 94 | "language": { 95 | "title": "言語", 96 | "th": "Thai", 97 | "en": "English", 98 | "jp": "日本語", 99 | "vi": "Vietnamese", 100 | "zh": "Chinese" 101 | }, 102 | "flag": { 103 | "title": "旗" 104 | }, 105 | "flag-color": { 106 | "title": "旗の色" 107 | }, 108 | "bomb": { 109 | "title": "爆弾" 110 | }, 111 | "number": { 112 | "title": "数字", 113 | "default": "(1) 通常", 114 | "roman": "(I) ローマ数字", 115 | "thai": "(๑) タイ数字", 116 | "abc": "(A) ABC", 117 | "question": "(?) 質問" 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /src/locales/th.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "lang": "th", 4 | "game-title": "เกมกู้ระเบิด", 5 | "game-desc": "ขุดหลุมเพื่อความสนุก!", 6 | "loading": "กำลังโหลด..." 7 | }, 8 | "not-found": { 9 | "title": "ไม่พบหน้านี้", 10 | "description": "กรุณากลับสู่หน้าแรก", 11 | "loading": "กำลังโหลด...", 12 | "button": "ย้อนกลับ" 13 | }, 14 | "difficulty": { 15 | "easy": "ง่าย", 16 | "medium": "ปานกลาง", 17 | "hard": "ยาก" 18 | }, 19 | "gameStats": { 20 | "timePlayed": "เวลาที่เล่น", 21 | "flagsPlaced": "จำนวนธง", 22 | "difficulty": "ระดับความยาก" 23 | }, 24 | "gameresult": { 25 | "description": "Minesweeper สนุก!", 26 | "win": "คุณชนะ!", 27 | "lose": "จบเกม!", 28 | "time": "เวลา", 29 | "score": "คะแนน", 30 | "difficulty": "ระดับความยาก", 31 | "flag": "จำนวนธง", 32 | "share-button": "แชร์", 33 | "again-button": "เล่นใหม่" 34 | }, 35 | "mainmenu": { 36 | "description": "สร้างโดย MeteorVIIx | เวอร์ชั่น {versionGame}", 37 | "settings": "ตั้งค่า", 38 | "singleplayer": { 39 | "title": "เล่นคนเดียว", 40 | "description": "เลือกระดับความยาก", 41 | "easy": "ง่าย", 42 | "medium": "ปานกลาง", 43 | "hard": "ยาก" 44 | }, 45 | "multiplayer": { 46 | "title": "เล่นหลายคน", 47 | "description": "เลือกการแข่งขัน", 48 | "casual-title": "จับคู่แบบสบายๆ", 49 | "casual-desc": "เล่นเพื่อความสนุกโดยไม่มีการเปลี่ยนแปลงอันดับ", 50 | "rank-title": "จับคู่แบบจัดอันดับ", 51 | "rank-desc": "แข่งขันเพื่อไต่อันดับ" 52 | }, 53 | "store": { 54 | "title": "ร้านค้า" 55 | }, 56 | "achievement": { 57 | "title": "ความสำเร็จ", 58 | "description": "ความสำเร็จทั้งหมดแสดงที่นี่", 59 | "menu-all": "ทั้งหมด", 60 | "menu-collected":"ได้รับแล้ว", 61 | "menu-not-collected": "ยังไม่ได้รับ" 62 | }, 63 | "quest": { 64 | "title": "ภารกิจ", 65 | "description": "ทำภารกิจประจำวันให้สำเร็จเพื่อรับรางวัล รีเซ็ตเวลาเที่ยงคืน", 66 | "reset": "รีเซ็ตครั้งต่อไปเวลา 00:00 น. (GMT+7)" 67 | } 68 | }, 69 | "achievement": { 70 | "complete": "สำเร็จ", 71 | "achieved": "ได้รับเมื่อ: {date}" 72 | }, 73 | "quest": { 74 | "complete": "สำเร็จ" 75 | }, 76 | "game": { 77 | "mine": "ระเบิด", 78 | "reset-tooltip": "เริ่มใหม่", 79 | "win": "คุณชนะ!", 80 | "lose": "จบเกม!", 81 | "flag-mode": "โหมดปักธง", 82 | "dig-mode": "โหมดขุด" 83 | }, 84 | "settings": { 85 | "title": "การตั้งค่า", 86 | "description": "ปรับแต่งได้อย่างอิสระ", 87 | "done-button": "ปิด", 88 | "theme": { 89 | "title": "ธีม", 90 | "system": "ระบบ", 91 | "light": "สว่าง", 92 | "dark": "มืด" 93 | }, 94 | "language": { 95 | "title": "ภาษา", 96 | "th": "ไทย", 97 | "en": "English", 98 | "jp": "Japanese", 99 | "vi": "Vietnamese", 100 | "zh": "Chinese" 101 | }, 102 | "flag": { 103 | "title": "ธง" 104 | }, 105 | "flag-color": { 106 | "title": "สีธง" 107 | }, 108 | "bomb": { 109 | "title": "ระเบิด" 110 | }, 111 | "number": { 112 | "title": "ตัวเลข", 113 | "default": "(1) ปกติ", 114 | "roman": "(I) โรมัน", 115 | "thai": "(๑) ไทย", 116 | "abc": "(A) ABC", 117 | "question": "(?) คำถาม" 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /src/locales/vi.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "lang": "vi", 4 | "game-title": "Dò Mìn", 5 | "game-desc": "Đào hố cho vui!", 6 | "loading": "Đang tải..." 7 | }, 8 | "not-found": { 9 | "title": "Không tìm thấy", 10 | "description": "Vui lòng quay lại trang chủ.", 11 | "loading": "Đang tải...", 12 | "button": "Mặt sau" 13 | }, 14 | "difficulty": { 15 | "easy": "Dễ", 16 | "medium": "Trung bình", 17 | "hard": "Khó" 18 | }, 19 | "gameStats": { 20 | "timePlayed": "Thời gian chơi", 21 | "flagsPlaced": "Cờ đã đặt", 22 | "difficulty": "Độ khó" 23 | }, 24 | "gameresult": { 25 | "description": "Minesweeper thật thú vị!", 26 | "win": "Bạn đã thắng!", 27 | "lose": "Trò chơi kết thúc!", 28 | "time": "Thời gian", 29 | "score": "Điểm số", 30 | "difficulty": "Độ khó", 31 | "flag": "Cờ đã đặt", 32 | "share-button": "Chia sẻ", 33 | "again-button": "Chơi lại" 34 | }, 35 | "mainmenu": { 36 | "description": "Tạo bởi MeteorVIIx | Phiên bản {versionGame}", 37 | "settings": "Cài đặt", 38 | "singleplayer": { 39 | "title": "Chơi đơn", 40 | "description": "Chọn độ khó", 41 | "easy": "Dễ", 42 | "medium": "Trung bình", 43 | "hard": "Khó" 44 | }, 45 | "multiplayer": { 46 | "title": "Nhiều người chơi", 47 | "description": "Chọn một cuộc thi", 48 | "casual-title": "Kết hợp thông thường", 49 | "casual-desc": "Chơi vui mà không thay đổi thứ hạng", 50 | "rank-title": "Kết hợp xếp hạng", 51 | "rank-desc": "Cạnh tranh để leo lên bảng xếp hạng" 52 | }, 53 | "store": { 54 | "title": "Cửa hàng" 55 | }, 56 | "achievement": { 57 | "title": "Thành tích", 58 | "description": "Tất cả thành tích được hiển thị ở đây", 59 | "menu-all": "Tất cả", 60 | "menu-collected":"Đã thu thập", 61 | "menu-not-collected": "Chưa thu thập" 62 | }, 63 | "quest": { 64 | "title": "Các nhiệm vụ", 65 | "description": "Hoàn thành nhiệm vụ hàng ngày để kiếm phần thưởng. Đặt lại vào lúc nửa đêm.", 66 | "reset": "Lần đặt lại tiếp theo lúc 00:00 (GMT+7)" 67 | } 68 | }, 69 | "achievement": { 70 | "complete": "Hoàn thành", 71 | "achieved": "Đạt được vào ngày: {date}" 72 | }, 73 | "quest": { 74 | "complete": "Hoàn thành" 75 | }, 76 | "game": { 77 | "mine": "Mìn", 78 | "reset-tooltip": "Chơi lại", 79 | "win": "Bạn đã thắng!", 80 | "lose": "Trò chơi kết thúc!", 81 | "flag-mode": "Chế độ gắn cờ", 82 | "dig-mode": "Chế độ đào" 83 | }, 84 | "settings": { 85 | "title": "Cài đặt", 86 | "description": "Tự do tùy chỉnh", 87 | "done-button": "xong", 88 | "theme": { 89 | "title": "Giao diện", 90 | "system": "Hệ thống", 91 | "light": "Sáng", 92 | "dark": "Tối" 93 | }, 94 | "language": { 95 | "title": "Ngôn ngữ", 96 | "th": "Thai", 97 | "en": "English", 98 | "jp": "Japanese", 99 | "vi": "Tiếng Việt", 100 | "zh": "Chinese" 101 | }, 102 | "flag": { 103 | "title": "Cờ" 104 | }, 105 | "flag-color": { 106 | "title": "Màu cờ" 107 | }, 108 | "bomb": { 109 | "title": "Mìn" 110 | }, 111 | "number": { 112 | "title": "Số", 113 | "default": "(1) Thường", 114 | "roman": "(I) La Mã", 115 | "thai": "(๑) Thái", 116 | "abc": "(A) ABC", 117 | "question": "(?) Câu hỏi" 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /src/locales/zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "lang": "zh", 4 | "game-title": "扫雷", 5 | "game-desc": "挖洞真好玩!", 6 | "loading": "載入中..." 7 | }, 8 | "not-found": { 9 | "title": "未找到", 10 | "description": "請返回主頁。", 11 | "loading": "載入中...", 12 | "button": "後退" 13 | }, 14 | "difficulty": { 15 | "easy": "简单", 16 | "medium": "中等", 17 | "hard": "困难" 18 | }, 19 | "gameStats": { 20 | "timePlayed": "游戏时间", 21 | "flagsPlaced": "已插旗数", 22 | "difficulty": "难度" 23 | }, 24 | "gameresult": { 25 | "description": "Minesweeper 很好玩!", 26 | "win": "胜利!", 27 | "lose": "游戏结束!", 28 | "time": "时间", 29 | "score": "得分", 30 | "difficulty": "难度", 31 | "flag": "已插旗数", 32 | "share-button": "分享", 33 | "again-button": "再玩一次" 34 | }, 35 | "mainmenu": { 36 | "description": "由 MeteorVIIx 制作 | 版本 {versionGame}", 37 | "settings": "设置", 38 | "singleplayer": { 39 | "title": "單人遊戲", 40 | "description": "選擇難度", 41 | "easy": "简单", 42 | "medium": "中等", 43 | "hard": "困难" 44 | }, 45 | "multiplayer": { 46 | "title": "多人遊戲", 47 | "description": "選擇比賽", 48 | "casual-title": "休閒匹配", 49 | "casual-desc": "享受樂趣,不改變排名", 50 | "rank-title": "排名匹配", 51 | "rank-desc": "競爭攀升排行榜" 52 | }, 53 | "store": { 54 | "title": "商店" 55 | }, 56 | "achievement": { 57 | "title": "成就", 58 | "description": "所有成就均顯示於此", 59 | "menu-all": "全部", 60 | "menu-collected": "已收藏", 61 | "menu-not-collected": "未收藏" 62 | }, 63 | "quest": { 64 | "title": "任務", 65 | "description": "完成日常任務以獲得獎勵。午夜重置。", 66 | "reset": "下次重置時間為 00:00 (GMT+7)" 67 | } 68 | }, 69 | "achievement": { 70 | "complete": "已完成", 71 | "achieved": "於完成: {date}" 72 | }, 73 | "quest": { 74 | "complete": "完全的" 75 | }, 76 | "game": { 77 | "mine": "地雷", 78 | "reset-tooltip": "重新开始", 79 | "win": "胜利!", 80 | "lose": "游戏结束!", 81 | "flag-mode": "标志模式", 82 | "dig-mode": "挖掘模式" 83 | }, 84 | "settings": { 85 | "title": "设置", 86 | "description": "自由訂製", 87 | "done-button": "完畢", 88 | "theme": { 89 | "title": "主题", 90 | "system": "系统", 91 | "light": "浅色", 92 | "dark": "深色" 93 | }, 94 | "language": { 95 | "title": "语言", 96 | "th": "Thai", 97 | "en": "English", 98 | "jp": "Japanese", 99 | "vi": "Vietnamese", 100 | "zh": "中文" 101 | }, 102 | "flag": { 103 | "title": "旗帜" 104 | }, 105 | "flag-color": { 106 | "title": "旗帜颜色" 107 | }, 108 | "bomb": { 109 | "title": "地雷" 110 | }, 111 | "number": { 112 | "title": "数字", 113 | "default": "(1) 普通", 114 | "roman": "(I) 罗马", 115 | "thai": "(๑) 泰文", 116 | "abc": "(A) ABC", 117 | "question": "(?) 问题" 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /src/providers/FontProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React, { createContext, useContext, useEffect } from 'react'; 3 | import { useLanguageStore } from '@/stores/languageStore'; 4 | import { 5 | Geist, 6 | Geist_Mono, 7 | Noto_Sans_Thai, 8 | Noto_Sans_JP, 9 | Noto_Sans_KR, 10 | Noto_Sans_SC, 11 | Be_Vietnam_Pro 12 | } from 'next/font/google'; 13 | 14 | const geistSans = Geist({ 15 | variable: '--font-geist-sans', 16 | subsets: ['latin'], 17 | display: 'swap', 18 | }); 19 | 20 | const geistMono = Geist_Mono({ 21 | variable: '--font-geist-mono', 22 | subsets: ['latin'], 23 | display: 'swap' 24 | }); 25 | 26 | const notoSansThai = Noto_Sans_Thai({ 27 | variable: '--font-thai', 28 | weight: ['400', '500', '600', '700'], 29 | subsets: ['thai'], 30 | display: 'swap' 31 | }); 32 | 33 | const notoSansJP = Noto_Sans_JP({ 34 | variable: '--font-japanese', 35 | weight: ['400', '500', '700'], 36 | subsets: ['latin'], 37 | display: 'swap' 38 | }); 39 | 40 | const notoSansKR = Noto_Sans_KR({ 41 | variable: '--font-korean', 42 | weight: ['400', '500', '700'], 43 | subsets: ['latin'], 44 | display: 'swap' 45 | }); 46 | 47 | const notoSansSC = Noto_Sans_SC({ 48 | variable: '--font-chinese', 49 | weight: ['400', '500', '700'], 50 | subsets: ['latin'], 51 | display: 'swap' 52 | }); 53 | 54 | const beVietnamPro = Be_Vietnam_Pro({ 55 | variable: '--font-vietnamese', 56 | weight: ['400', '500', '700'], 57 | subsets: ['latin'], 58 | display: 'swap' 59 | }); 60 | 61 | const FontContext = createContext<{ fontClass: string }>({ fontClass: '' }); 62 | 63 | export const useFontContext = () => useContext(FontContext); 64 | 65 | export const FontProvider = ({ children }: { children: React.ReactNode }) => { 66 | const { lang } = useLanguageStore(); 67 | 68 | const getFontClassForLocale = () => { 69 | switch (lang) { 70 | case 'th': 71 | return `${geistSans.variable} ${geistMono.variable} ${notoSansThai.variable} font-thai`; 72 | case 'jp': 73 | return `${geistSans.variable} ${geistMono.variable} ${notoSansJP.variable} font-japanese`; 74 | case 'ko': 75 | return `${geistSans.variable} ${geistMono.variable} ${notoSansKR.variable} font-korean`; 76 | case 'zh': 77 | return `${geistSans.variable} ${geistMono.variable} ${notoSansSC.variable} font-chinese`; 78 | case 'vi': 79 | return `${geistSans.variable} ${geistMono.variable} ${beVietnamPro.variable} font-vietnamese`; 80 | default: 81 | return `${geistSans.variable} ${geistMono.variable} font-sans`; 82 | } 83 | }; 84 | 85 | const fontClass = getFontClassForLocale(); 86 | 87 | useEffect(() => { 88 | if (typeof document !== 'undefined') { 89 | document.documentElement.className = ''; 90 | document.documentElement.classList.add('antialiased', ...fontClass.split(' ')); 91 | } 92 | }, [fontClass]); 93 | 94 | return ( 95 | 96 | {children} 97 | 98 | ) 99 | }; 100 | 101 | export const allFonts = [ 102 | geistSans.variable, 103 | geistMono.variable, 104 | notoSansThai.variable, 105 | notoSansJP.variable, 106 | notoSansKR.variable, 107 | notoSansSC.variable, 108 | beVietnamPro.variable 109 | ]; -------------------------------------------------------------------------------- /src/providers/LanguageProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useEffect } from 'react'; 3 | import { useLanguageStore } from '@/stores/languageStore'; 4 | 5 | const LanguageProvider = () => { 6 | const { lang } = useLanguageStore(); 7 | 8 | useEffect(() => { 9 | document.documentElement.lang = lang; 10 | }, [lang]); 11 | 12 | return null; 13 | }; 14 | 15 | export { LanguageProvider }; -------------------------------------------------------------------------------- /src/providers/SolanaProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import React, { useEffect, useMemo, useCallback } from 'react'; 3 | import { PhantomWalletAdapter, SolflareWalletAdapter, TorusWalletAdapter, LedgerWalletAdapter } from '@solana/wallet-adapter-wallets'; 4 | import { WalletAdapterNetwork } from '@solana/wallet-adapter-base'; 5 | import { ConnectionProvider, WalletProvider, useWallet } from '@solana/wallet-adapter-react'; 6 | import { WalletModalProvider } from '@solana/wallet-adapter-react-ui'; 7 | import { clusterApiUrl } from '@solana/web3.js'; 8 | import { useWalletStore } from '@/stores'; 9 | 10 | const networkRPC = process.env.NETWORK_RPC as 'mainnet' | 'testnet' | 'devnet'; 11 | 12 | export interface SolanaProviderProps { 13 | children: React.ReactNode; 14 | }; 15 | 16 | const WalletStateUpdater = () => { 17 | const { publicKey, connected, connecting } = useWallet(); 18 | const { setConnected, setPublicKey, setConnecting } = useWalletStore(); 19 | 20 | const publicKeyString = useMemo(() => publicKey?.toString() || null, [publicKey]); 21 | 22 | const updateWalletState = useCallback(() => { 23 | setConnected(connected); 24 | 25 | if (publicKeyString !== useWalletStore.getState().publicKey) { 26 | setPublicKey(publicKeyString); 27 | } 28 | 29 | if (connecting !== useWalletStore.getState().connecting) { 30 | setConnecting(connecting); 31 | } 32 | }, [connected, publicKeyString, connecting, setConnected, setPublicKey, setConnecting]); 33 | 34 | useEffect(() => { 35 | updateWalletState(); 36 | }, [updateWalletState]); 37 | 38 | return null; 39 | }; 40 | 41 | const SolanaProvider = ({ children }: SolanaProviderProps) => { 42 | const network = useMemo(() => { 43 | switch(networkRPC) { 44 | case 'mainnet': 45 | return WalletAdapterNetwork.Mainnet; 46 | case 'testnet': 47 | return WalletAdapterNetwork.Testnet; 48 | case 'devnet': 49 | default: 50 | return WalletAdapterNetwork.Devnet; 51 | } 52 | }, []); 53 | 54 | const endpoint = useMemo(() => clusterApiUrl(network), [network]); 55 | 56 | const wallets = useMemo(() => [ 57 | new PhantomWalletAdapter(), 58 | new SolflareWalletAdapter(), 59 | new TorusWalletAdapter(), 60 | new LedgerWalletAdapter() 61 | ], []); 62 | 63 | const walletConfig = useMemo(() => ({ 64 | wallets, 65 | autoConnect: true 66 | }), [wallets]); 67 | 68 | return ( 69 | 70 | 71 | 72 | 73 | {children} 74 | 75 | 76 | 77 | ) 78 | }; 79 | 80 | export default SolanaProvider; -------------------------------------------------------------------------------- /src/providers/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { useEffect, useState } from 'react'; 3 | import { ThemeProvider as NextThemeProvider } from 'next-themes'; 4 | 5 | const ThemeProvider = ({ children }: { children: React.ReactNode }) => { 6 | const [ mounted, setMounted ] = useState(false); 7 | 8 | useEffect(() => { 9 | setMounted(true); 10 | }, []); 11 | 12 | if (!mounted) { 13 | return ( 14 | <> 15 | {children} 16 | 17 | ) 18 | }; 19 | 20 | return ( 21 | 26 | {children} 27 | 28 | ); 29 | } 30 | 31 | export { ThemeProvider }; -------------------------------------------------------------------------------- /src/providers/Web3Provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import dynamic from 'next/dynamic'; 3 | import React, { useEffect, useState } from 'react'; 4 | import { useTheme } from 'next-themes'; 5 | 6 | const SolanaProvider = dynamic(() => import('@/providers/SolanaProvider'), { ssr: false }); 7 | 8 | const Web3Provider = ({ children }: { children: React.ReactNode }) => { 9 | const { theme } = useTheme(); 10 | const [ isTauri, setIsTauri ] = useState(false); 11 | const [ isClient, setIsClient ] = useState(false); 12 | 13 | useEffect(() => { 14 | setIsClient(true); 15 | const checkTauri = () => typeof window !== 'undefined' && 'TAURI' in window; 16 | setIsTauri(checkTauri()); 17 | document.documentElement.setAttribute('data-theme', theme || 'dark'); 18 | }, [theme]); 19 | 20 | if (!isClient || isTauri) { 21 | return <>{children}; 22 | } 23 | 24 | return {children}; 25 | }; 26 | 27 | export { Web3Provider }; -------------------------------------------------------------------------------- /src/stores/commonStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { CommonStateProps } from '@/types'; 3 | 4 | export const useCommonStore = create((set) => ({ 5 | isMenuQuestOpen: false, 6 | isMenuAchievementOpen: false, 7 | isMenuSettingOpen: false, 8 | isMenuSingleplayerOpen: false, 9 | isMenuMultiplayerOpen: false, 10 | setIsMenuQuestOpen: (isMenuQuestOpen) => set({ isMenuQuestOpen }), 11 | setIsMenuAchievementOpen: (isMenuAchievementOpen) => set({ isMenuAchievementOpen }), 12 | setIsMenuSettingOpen: (isMenuSettingOpen) => set({ isMenuSettingOpen }), 13 | setIsMenuSingleplayerOpen: (isMenuSingleplayerOpen) => set({ isMenuSingleplayerOpen }), 14 | setIsMenuMultiplayerOpen: (isMenuMultiplayerOpen) => set({ isMenuMultiplayerOpen }) 15 | })); -------------------------------------------------------------------------------- /src/stores/gameStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { GameStateProps } from '@/types'; 3 | 4 | export const useGameStore = create((set) => ({ 5 | isStartGame: false, 6 | isGameOver: false, 7 | isGameWon: false, 8 | isShowResult: false, 9 | board: [], 10 | difficulty: 'easy', 11 | isFlagMode: false, 12 | flagsPlaced: 0, 13 | time: 0, 14 | score: 0, 15 | setIsStartGame: (isStartGame) => set({ isStartGame }), 16 | setIsGameOver: (isGameOver) => set({ isGameOver }), 17 | setIsGameWon: (isGameWon) => set({ isGameWon }), 18 | setIsShowResult: (isShowResult) => set({ isShowResult }), 19 | setBoard: (board) => set({ board }), 20 | setDifficulty: (difficulty) => set({ difficulty }), 21 | setIsFlagMode: (isFlagMode) => set({ isFlagMode }), 22 | setFlagsPlaced: (flagsPlaced) => set({ flagsPlaced }), 23 | setTime: (time) => set({ time }), 24 | setScore: (score) => set({ score }) 25 | })); -------------------------------------------------------------------------------- /src/stores/index.ts: -------------------------------------------------------------------------------- 1 | import { useCommonStore } from './commonStore'; 2 | import { useGameStore } from './gameStore'; 3 | import { useLanguageStore } from './languageStore'; 4 | import { useSettingStore } from './settingStore'; 5 | import { useWalletStore } from './walletStore'; 6 | 7 | export { 8 | useCommonStore, 9 | useGameStore, 10 | useLanguageStore, 11 | useSettingStore, 12 | useWalletStore 13 | }; -------------------------------------------------------------------------------- /src/stores/languageStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { persist } from 'zustand/middleware'; 3 | import { LanguageStateProps } from '@/types'; 4 | 5 | export const useLanguageStore = create()( 6 | persist( 7 | (set) => ({ 8 | lang: 'en', 9 | setLang: (lang) => set({ lang }) 10 | }), 11 | { name: 'language-store' } 12 | ) 13 | ); -------------------------------------------------------------------------------- /src/stores/settingStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { persist } from 'zustand/middleware'; 3 | import { SettingStateProps } from '@/types'; 4 | 5 | export const useSettingStore = create()( 6 | persist( 7 | (set) => ({ 8 | flagIcon: 'default', 9 | flagColor: '#FF0000', 10 | bombIcon: 'default', 11 | numberStyle: 'default', 12 | setFlagIcon: (flagIcon) => set({ flagIcon }), 13 | setFlagColor: (flagColor) => set({ flagColor }), 14 | setBombIcon: (bombIcon) => set({ bombIcon }), 15 | setNumberStyle: (numberStyle) => set({ numberStyle }), 16 | }), 17 | { 18 | name: 'setting-storage', 19 | partialize: (state) => ({ 20 | flagIcon: state.flagIcon, 21 | flagColor: state.flagColor, 22 | bombIcon: state.bombIcon, 23 | numberStyle: state.numberStyle 24 | }), 25 | } 26 | ) 27 | ); -------------------------------------------------------------------------------- /src/stores/walletStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { persist } from 'zustand/middleware'; 3 | 4 | interface WalletState { 5 | connected: boolean; 6 | publicKey: string | null; 7 | connecting: boolean; 8 | 9 | setConnected: (status: boolean) => void; 10 | setPublicKey: (key: string | null) => void; 11 | setConnecting: (status: boolean) => void; 12 | }; 13 | 14 | export const useWalletStore = create()( 15 | persist( 16 | (set) => ({ 17 | connected: false, 18 | publicKey: null, 19 | connecting: false, 20 | 21 | setConnected: (status) => set({ connected: status }), 22 | setPublicKey: (key) => set({ publicKey: key }), 23 | setConnecting: (status) => set({ connecting: status }) 24 | }), 25 | { 26 | name: 'wallet-storage', 27 | partialize: (state) => ({ 28 | connected: state.connected, 29 | publicKey: state.publicKey 30 | }), 31 | } 32 | ) 33 | ); -------------------------------------------------------------------------------- /src/types/css.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css' { 2 | const content: { [className: string]: string }; 3 | export default content; 4 | } -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { CommonStateProps, GameStateProps, SettingStateProps } from './store'; 2 | 3 | export { CommonStateProps, GameStateProps, SettingStateProps }; 4 | 5 | export type Language = 'en' | 'th' | 'jp' | 'vi' | 'zh'; 6 | export type FlagStyle = 'default' | 'pyramid' | 'radar' | 'sparkles' | 'sigma'; 7 | export type BombStyle = 'default' | 'skull' | 'fire' | 'flame' | 'ghost'; 8 | export type NumberStyle = 'default' | 'roman' | 'thai' | 'abc' | 'question'; 9 | export type Difficulty = 'easy' | 'medium' | 'hard'; 10 | 11 | export type TranslationValue = string | { [key: string]: TranslationValue }; 12 | 13 | export type Variables = { 14 | [key: string]: string | number | ReactNode 15 | }; 16 | export type Translation = { 17 | [key: string]: TranslationValue 18 | }; 19 | 20 | export type TranslationsType = { 21 | [K in Language]: { 22 | [key: string]: TranslationValue; 23 | }; 24 | }; 25 | 26 | export interface LanguageStateProps { 27 | lang: string; 28 | setLang: (lang: string) => void; 29 | }; 30 | 31 | export interface GameStats { 32 | time: number; 33 | difficulty: Difficulty; 34 | flagsPlaced: number; 35 | score: number; 36 | }; 37 | 38 | export interface Cell { 39 | isMine: boolean; 40 | isRevealed: boolean; 41 | isFlagged: boolean; 42 | neighborMines: number; 43 | }; 44 | -------------------------------------------------------------------------------- /src/types/store.d.ts: -------------------------------------------------------------------------------- 1 | export interface CommonStateProps { 2 | isMenuQuestOpen: boolean; 3 | isMenuAchievementOpen: boolean; 4 | isMenuSettingOpen: boolean; 5 | isMenuSingleplayerOpen: boolean; 6 | isMenuMultiplayerOpen: boolean; 7 | setIsMenuQuestOpen: (isMenuQuestOpen) => void; 8 | setIsMenuAchievementOpen: (isAchievementOpen) => void; 9 | setIsMenuSettingOpen: (isMenuSettingOpen) => void; 10 | setIsMenuSingleplayerOpen: (isMenuSingleplayerOpen) => void; 11 | setIsMenuMultiplayerOpen: (isMenuMultiplayerOpen) => void; 12 | }; 13 | export interface GameStateProps { 14 | isStartGame: boolean; 15 | isGameOver: boolean; 16 | isGameWon: boolean; 17 | isShowResult: boolean; 18 | board: Cell[][]; 19 | difficulty: Difficulty; 20 | isFlagMode: boolean; 21 | flagsPlaced: number; 22 | time: number; 23 | score: number; 24 | setIsStartGame: (isStartGame) => void; 25 | setIsGameOver: (isGameOver) => void; 26 | setIsGameWon: (isGameWon) => void; 27 | setIsShowResult: (isShowResult) => void; 28 | setBoard: (board) => void; 29 | setDifficulty: (difficulty) => void; 30 | setIsFlagMode: (isFlagMode) => void; 31 | setFlagsPlaced: (flagsPlaced) => void; 32 | setTime: (time) => void; 33 | setScore: (score) => void; 34 | }; 35 | 36 | export interface SettingStateProps { 37 | flagIcon: FlagStyle; 38 | flagColor: string; 39 | bombIcon: BombStyle; 40 | numberStyle: NumberStyle; 41 | setFlagIcon: (flagIcon) => void; 42 | setFlagColor: (flagColor) => void; 43 | setBombIcon: (bombIcon) => void; 44 | setNumberStyle: (numberStyle) => void; 45 | }; -------------------------------------------------------------------------------- /src/utils/discord-webhook.ts: -------------------------------------------------------------------------------- 1 | interface DataProps { 2 | publicKey: string | null; 3 | imageBlob: Blob; 4 | }; 5 | 6 | export const sendWebhookDiscordShare = async (data: DataProps) => { 7 | try { 8 | const formData = new FormData(); 9 | formData.append('publicKey', data.publicKey || 'Anonymous Player'); 10 | formData.append('file', data.imageBlob, 'minesweeper-score.png'); 11 | 12 | const response = await fetch('/api/discord-webhook', { 13 | method: 'POST', 14 | body: formData 15 | }); 16 | 17 | if (!response.ok) { 18 | throw new Error(`Server responded with ${response.status}`); 19 | } 20 | 21 | return await response.json(); 22 | } catch (error) { 23 | console.error('Error calling webhook API:', error); 24 | throw error; 25 | } 26 | }; -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | export default { 4 | content: [ 5 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './src/app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | background: 'hsl(var(--background))', 13 | foreground: 'hsl(var(--foreground))', 14 | card: { 15 | DEFAULT: 'hsl(var(--card))', 16 | foreground: 'hsl(var(--card-foreground))' 17 | }, 18 | popover: { 19 | DEFAULT: 'hsl(var(--popover))', 20 | foreground: 'hsl(var(--popover-foreground))' 21 | }, 22 | primary: { 23 | DEFAULT: 'hsl(var(--primary))', 24 | foreground: 'hsl(var(--primary-foreground))' 25 | }, 26 | secondary: { 27 | DEFAULT: 'hsl(var(--secondary))', 28 | foreground: 'hsl(var(--secondary-foreground))' 29 | }, 30 | muted: { 31 | DEFAULT: 'hsl(var(--muted))', 32 | foreground: 'hsl(var(--muted-foreground))' 33 | }, 34 | accent: { 35 | DEFAULT: 'hsl(var(--accent))', 36 | foreground: 'hsl(var(--accent-foreground))' 37 | }, 38 | destructive: { 39 | DEFAULT: 'hsl(var(--destructive))', 40 | foreground: 'hsl(var(--destructive-foreground))' 41 | }, 42 | border: 'hsl(var(--border))', 43 | input: 'hsl(var(--input))', 44 | ring: 'hsl(var(--ring))', 45 | chart: { 46 | '1': 'hsl(var(--chart-1))', 47 | '2': 'hsl(var(--chart-2))', 48 | '3': 'hsl(var(--chart-3))', 49 | '4': 'hsl(var(--chart-4))', 50 | '5': 'hsl(var(--chart-5))' 51 | } 52 | }, 53 | borderRadius: { 54 | lg: 'var(--radius)', 55 | md: 'calc(var(--radius) - 2px)', 56 | sm: 'calc(var(--radius) - 4px)' 57 | }, 58 | animation: { 59 | 'shimmer-slide': 'shimmer-slide var(--speed) ease-in-out infinite alternate', 60 | 'spin-around': 'spin-around calc(var(--speed) * 2) infinite linear' 61 | }, 62 | keyframes: { 63 | 'shimmer-slide': { 64 | to: { 65 | transform: 'translate(calc(100cqw - 100%), 0)' 66 | } 67 | }, 68 | 'spin-around': { 69 | '0%': { 70 | transform: 'translateZ(0) rotate(0)' 71 | }, 72 | '15%, 35%': { 73 | transform: 'translateZ(0) rotate(90deg)' 74 | }, 75 | '65%, 85%': { 76 | transform: 'translateZ(0) rotate(270deg)' 77 | }, 78 | '100%': { 79 | transform: 'translateZ(0) rotate(360deg)' 80 | } 81 | } 82 | } 83 | } 84 | }, 85 | plugins: [], 86 | } satisfies Config; 87 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------