diff --git a/angular.json b/angular.json
index fd1f73a..aafdf64 100644
--- a/angular.json
+++ b/angular.json
@@ -2,7 +2,8 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
- "packageManager": "npm"
+ "packageManager": "npm",
+ "analytics": false
},
"newProjectRoot": "projects",
"projects": {
@@ -28,8 +29,13 @@
},
{
"glob": "**/*",
- "input": "arabian-chess",
- "output": "/arabian-chess"
+ "input": "assets/arabian-chess",
+ "output": "/assets/arabian-chess"
+ },
+ {
+ "glob": "**/*",
+ "input": "assets/ChessAssets",
+ "output": "/assets/ChessAssets"
}
],
"styles": [
@@ -46,8 +52,8 @@
},
{
"type": "anyComponentStyle",
- "maximumWarning": "4kB",
- "maximumError": "8kB"
+ "maximumWarning": "12kB",
+ "maximumError": "20kB"
}
],
"outputHashing": "all"
@@ -69,7 +75,9 @@
"serve": {
"builder": "@angular/build:dev-server",
"options": {
- "proxyConfig": "proxy.conf.json"
+ "proxyConfig": "proxy.conf.json",
+ "host": "0.0.0.0",
+ "port": 4200
},
"configurations": {
"production": {
@@ -99,8 +107,13 @@
},
{
"glob": "**/*",
- "input": "arabian-chess",
- "output": "/arabian-chess"
+ "input": "assets/arabian-chess",
+ "output": "/assets/arabian-chess"
+ },
+ {
+ "glob": "**/*",
+ "input": "assets/ChessAssets",
+ "output": "/assets/ChessAssets"
}
],
"styles": [
diff --git a/assets/ChessAssets/b_Bishop.png b/assets/ChessAssets/b_Bishop.png
new file mode 100644
index 0000000..26458d8
Binary files /dev/null and b/assets/ChessAssets/b_Bishop.png differ
diff --git a/assets/ChessAssets/b_King.png b/assets/ChessAssets/b_King.png
new file mode 100644
index 0000000..9a7d13f
Binary files /dev/null and b/assets/ChessAssets/b_King.png differ
diff --git a/assets/ChessAssets/b_Knight.png b/assets/ChessAssets/b_Knight.png
new file mode 100644
index 0000000..ec3b8ba
Binary files /dev/null and b/assets/ChessAssets/b_Knight.png differ
diff --git a/assets/ChessAssets/b_Pawn.png b/assets/ChessAssets/b_Pawn.png
new file mode 100644
index 0000000..891e2d9
Binary files /dev/null and b/assets/ChessAssets/b_Pawn.png differ
diff --git a/assets/ChessAssets/b_Queen.png b/assets/ChessAssets/b_Queen.png
new file mode 100644
index 0000000..a87a5dc
Binary files /dev/null and b/assets/ChessAssets/b_Queen.png differ
diff --git a/assets/ChessAssets/b_Rook.png b/assets/ChessAssets/b_Rook.png
new file mode 100644
index 0000000..afcca80
Binary files /dev/null and b/assets/ChessAssets/b_Rook.png differ
diff --git a/assets/ChessAssets/w_Bishop.png b/assets/ChessAssets/w_Bishop.png
new file mode 100644
index 0000000..473f5f4
Binary files /dev/null and b/assets/ChessAssets/w_Bishop.png differ
diff --git a/assets/ChessAssets/w_King.png b/assets/ChessAssets/w_King.png
new file mode 100644
index 0000000..b34a354
Binary files /dev/null and b/assets/ChessAssets/w_King.png differ
diff --git a/assets/ChessAssets/w_Knight.png b/assets/ChessAssets/w_Knight.png
new file mode 100644
index 0000000..f824639
Binary files /dev/null and b/assets/ChessAssets/w_Knight.png differ
diff --git a/assets/ChessAssets/w_Pawn.png b/assets/ChessAssets/w_Pawn.png
new file mode 100644
index 0000000..185ca0a
Binary files /dev/null and b/assets/ChessAssets/w_Pawn.png differ
diff --git a/assets/ChessAssets/w_Queen.png b/assets/ChessAssets/w_Queen.png
new file mode 100644
index 0000000..735cad5
Binary files /dev/null and b/assets/ChessAssets/w_Queen.png differ
diff --git a/assets/ChessAssets/w_Rook.png b/assets/ChessAssets/w_Rook.png
new file mode 100644
index 0000000..217acde
Binary files /dev/null and b/assets/ChessAssets/w_Rook.png differ
diff --git a/arabian-chess/license.txt b/assets/arabian-chess/license.txt
similarity index 100%
rename from arabian-chess/license.txt
rename to assets/arabian-chess/license.txt
diff --git a/arabian-chess/plane.png b/assets/arabian-chess/plane.png
similarity index 100%
rename from arabian-chess/plane.png
rename to assets/arabian-chess/plane.png
diff --git a/arabian-chess/player-one.gif b/assets/arabian-chess/player-one.gif
similarity index 100%
rename from arabian-chess/player-one.gif
rename to assets/arabian-chess/player-one.gif
diff --git a/arabian-chess/player-two.gif b/assets/arabian-chess/player-two.gif
similarity index 100%
rename from arabian-chess/player-two.gif
rename to assets/arabian-chess/player-two.gif
diff --git a/arabian-chess/raf.gif b/assets/arabian-chess/raf.gif
similarity index 100%
rename from arabian-chess/raf.gif
rename to assets/arabian-chess/raf.gif
diff --git a/arabian-chess/ref/cover.png b/assets/arabian-chess/ref/cover.png
similarity index 100%
rename from arabian-chess/ref/cover.png
rename to assets/arabian-chess/ref/cover.png
diff --git a/arabian-chess/ref/full_art.png b/assets/arabian-chess/ref/full_art.png
similarity index 100%
rename from arabian-chess/ref/full_art.png
rename to assets/arabian-chess/ref/full_art.png
diff --git a/arabian-chess/ref/logo.png b/assets/arabian-chess/ref/logo.png
similarity index 100%
rename from arabian-chess/ref/logo.png
rename to assets/arabian-chess/ref/logo.png
diff --git a/arabian-chess/sheets/board.png b/assets/arabian-chess/sheets/board.png
similarity index 100%
rename from arabian-chess/sheets/board.png
rename to assets/arabian-chess/sheets/board.png
diff --git a/arabian-chess/sheets/board_centered.png b/assets/arabian-chess/sheets/board_centered.png
similarity index 100%
rename from arabian-chess/sheets/board_centered.png
rename to assets/arabian-chess/sheets/board_centered.png
diff --git a/arabian-chess/sheets/board_without_bottom.png b/assets/arabian-chess/sheets/board_without_bottom.png
similarity index 100%
rename from arabian-chess/sheets/board_without_bottom.png
rename to assets/arabian-chess/sheets/board_without_bottom.png
diff --git a/arabian-chess/sheets/nums & letters.png b/assets/arabian-chess/sheets/nums & letters.png
similarity index 100%
rename from arabian-chess/sheets/nums & letters.png
rename to assets/arabian-chess/sheets/nums & letters.png
diff --git a/arabian-chess/sheets/pieces.png b/assets/arabian-chess/sheets/pieces.png
similarity index 100%
rename from arabian-chess/sheets/pieces.png
rename to assets/arabian-chess/sheets/pieces.png
diff --git a/arabian-chess/sprites/board/board_bottom.png b/assets/arabian-chess/sprites/board/board_bottom.png
similarity index 100%
rename from arabian-chess/sprites/board/board_bottom.png
rename to assets/arabian-chess/sprites/board/board_bottom.png
diff --git a/arabian-chess/sprites/board/board_square_black.png b/assets/arabian-chess/sprites/board/board_square_black.png
similarity index 100%
rename from arabian-chess/sprites/board/board_square_black.png
rename to assets/arabian-chess/sprites/board/board_square_black.png
diff --git a/arabian-chess/sprites/board/board_square_white.png b/assets/arabian-chess/sprites/board/board_square_white.png
similarity index 100%
rename from arabian-chess/sprites/board/board_square_white.png
rename to assets/arabian-chess/sprites/board/board_square_white.png
diff --git a/arabian-chess/sprites/nums & letters/letter_a.png b/assets/arabian-chess/sprites/nums & letters/letter_a.png
similarity index 100%
rename from arabian-chess/sprites/nums & letters/letter_a.png
rename to assets/arabian-chess/sprites/nums & letters/letter_a.png
diff --git a/arabian-chess/sprites/nums & letters/letter_b.png b/assets/arabian-chess/sprites/nums & letters/letter_b.png
similarity index 100%
rename from arabian-chess/sprites/nums & letters/letter_b.png
rename to assets/arabian-chess/sprites/nums & letters/letter_b.png
diff --git a/arabian-chess/sprites/nums & letters/letter_c.png b/assets/arabian-chess/sprites/nums & letters/letter_c.png
similarity index 100%
rename from arabian-chess/sprites/nums & letters/letter_c.png
rename to assets/arabian-chess/sprites/nums & letters/letter_c.png
diff --git a/arabian-chess/sprites/nums & letters/letter_d.png b/assets/arabian-chess/sprites/nums & letters/letter_d.png
similarity index 100%
rename from arabian-chess/sprites/nums & letters/letter_d.png
rename to assets/arabian-chess/sprites/nums & letters/letter_d.png
diff --git a/arabian-chess/sprites/nums & letters/letter_e.png b/assets/arabian-chess/sprites/nums & letters/letter_e.png
similarity index 100%
rename from arabian-chess/sprites/nums & letters/letter_e.png
rename to assets/arabian-chess/sprites/nums & letters/letter_e.png
diff --git a/arabian-chess/sprites/nums & letters/letter_f.png b/assets/arabian-chess/sprites/nums & letters/letter_f.png
similarity index 100%
rename from arabian-chess/sprites/nums & letters/letter_f.png
rename to assets/arabian-chess/sprites/nums & letters/letter_f.png
diff --git a/arabian-chess/sprites/nums & letters/letter_g.png b/assets/arabian-chess/sprites/nums & letters/letter_g.png
similarity index 100%
rename from arabian-chess/sprites/nums & letters/letter_g.png
rename to assets/arabian-chess/sprites/nums & letters/letter_g.png
diff --git a/arabian-chess/sprites/nums & letters/letter_h.png b/assets/arabian-chess/sprites/nums & letters/letter_h.png
similarity index 100%
rename from arabian-chess/sprites/nums & letters/letter_h.png
rename to assets/arabian-chess/sprites/nums & letters/letter_h.png
diff --git a/arabian-chess/sprites/nums & letters/num_0.png b/assets/arabian-chess/sprites/nums & letters/num_0.png
similarity index 100%
rename from arabian-chess/sprites/nums & letters/num_0.png
rename to assets/arabian-chess/sprites/nums & letters/num_0.png
diff --git a/arabian-chess/sprites/nums & letters/num_1.png b/assets/arabian-chess/sprites/nums & letters/num_1.png
similarity index 100%
rename from arabian-chess/sprites/nums & letters/num_1.png
rename to assets/arabian-chess/sprites/nums & letters/num_1.png
diff --git a/arabian-chess/sprites/nums & letters/num_2.png b/assets/arabian-chess/sprites/nums & letters/num_2.png
similarity index 100%
rename from arabian-chess/sprites/nums & letters/num_2.png
rename to assets/arabian-chess/sprites/nums & letters/num_2.png
diff --git a/arabian-chess/sprites/nums & letters/num_3.png b/assets/arabian-chess/sprites/nums & letters/num_3.png
similarity index 100%
rename from arabian-chess/sprites/nums & letters/num_3.png
rename to assets/arabian-chess/sprites/nums & letters/num_3.png
diff --git a/arabian-chess/sprites/nums & letters/num_4.png b/assets/arabian-chess/sprites/nums & letters/num_4.png
similarity index 100%
rename from arabian-chess/sprites/nums & letters/num_4.png
rename to assets/arabian-chess/sprites/nums & letters/num_4.png
diff --git a/arabian-chess/sprites/nums & letters/num_5.png b/assets/arabian-chess/sprites/nums & letters/num_5.png
similarity index 100%
rename from arabian-chess/sprites/nums & letters/num_5.png
rename to assets/arabian-chess/sprites/nums & letters/num_5.png
diff --git a/arabian-chess/sprites/nums & letters/num_6.png b/assets/arabian-chess/sprites/nums & letters/num_6.png
similarity index 100%
rename from arabian-chess/sprites/nums & letters/num_6.png
rename to assets/arabian-chess/sprites/nums & letters/num_6.png
diff --git a/arabian-chess/sprites/nums & letters/num_7.png b/assets/arabian-chess/sprites/nums & letters/num_7.png
similarity index 100%
rename from arabian-chess/sprites/nums & letters/num_7.png
rename to assets/arabian-chess/sprites/nums & letters/num_7.png
diff --git a/arabian-chess/sprites/pieces/black_bishop.png b/assets/arabian-chess/sprites/pieces/black_bishop.png
similarity index 100%
rename from arabian-chess/sprites/pieces/black_bishop.png
rename to assets/arabian-chess/sprites/pieces/black_bishop.png
diff --git a/arabian-chess/sprites/pieces/black_king.png b/assets/arabian-chess/sprites/pieces/black_king.png
similarity index 100%
rename from arabian-chess/sprites/pieces/black_king.png
rename to assets/arabian-chess/sprites/pieces/black_king.png
diff --git a/arabian-chess/sprites/pieces/black_knight.png b/assets/arabian-chess/sprites/pieces/black_knight.png
similarity index 100%
rename from arabian-chess/sprites/pieces/black_knight.png
rename to assets/arabian-chess/sprites/pieces/black_knight.png
diff --git a/arabian-chess/sprites/pieces/black_pawn.png b/assets/arabian-chess/sprites/pieces/black_pawn.png
similarity index 100%
rename from arabian-chess/sprites/pieces/black_pawn.png
rename to assets/arabian-chess/sprites/pieces/black_pawn.png
diff --git a/arabian-chess/sprites/pieces/black_queen.png b/assets/arabian-chess/sprites/pieces/black_queen.png
similarity index 100%
rename from arabian-chess/sprites/pieces/black_queen.png
rename to assets/arabian-chess/sprites/pieces/black_queen.png
diff --git a/arabian-chess/sprites/pieces/black_rook.png b/assets/arabian-chess/sprites/pieces/black_rook.png
similarity index 100%
rename from arabian-chess/sprites/pieces/black_rook.png
rename to assets/arabian-chess/sprites/pieces/black_rook.png
diff --git a/arabian-chess/sprites/pieces/white_bishop.png b/assets/arabian-chess/sprites/pieces/white_bishop.png
similarity index 100%
rename from arabian-chess/sprites/pieces/white_bishop.png
rename to assets/arabian-chess/sprites/pieces/white_bishop.png
diff --git a/arabian-chess/sprites/pieces/white_king.png b/assets/arabian-chess/sprites/pieces/white_king.png
similarity index 100%
rename from arabian-chess/sprites/pieces/white_king.png
rename to assets/arabian-chess/sprites/pieces/white_king.png
diff --git a/arabian-chess/sprites/pieces/white_knight.png b/assets/arabian-chess/sprites/pieces/white_knight.png
similarity index 100%
rename from arabian-chess/sprites/pieces/white_knight.png
rename to assets/arabian-chess/sprites/pieces/white_knight.png
diff --git a/arabian-chess/sprites/pieces/white_pawn.png b/assets/arabian-chess/sprites/pieces/white_pawn.png
similarity index 100%
rename from arabian-chess/sprites/pieces/white_pawn.png
rename to assets/arabian-chess/sprites/pieces/white_pawn.png
diff --git a/arabian-chess/sprites/pieces/white_queen.png b/assets/arabian-chess/sprites/pieces/white_queen.png
similarity index 100%
rename from arabian-chess/sprites/pieces/white_queen.png
rename to assets/arabian-chess/sprites/pieces/white_queen.png
diff --git a/arabian-chess/sprites/pieces/white_rook.png b/assets/arabian-chess/sprites/pieces/white_rook.png
similarity index 100%
rename from arabian-chess/sprites/pieces/white_rook.png
rename to assets/arabian-chess/sprites/pieces/white_rook.png
diff --git a/arabian-chess/xav.png b/assets/arabian-chess/xav.png
similarity index 100%
rename from arabian-chess/xav.png
rename to assets/arabian-chess/xav.png
diff --git a/arabian-chess/zayne.png b/assets/arabian-chess/zayne.png
similarity index 100%
rename from arabian-chess/zayne.png
rename to assets/arabian-chess/zayne.png
diff --git a/package-lock.json b/package-lock.json
index 323270f..6c981c5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -458,7 +458,6 @@
"resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.19.tgz",
"integrity": "sha512-hcB1eUEN8LGcKGc4DlRJ+abS6AYfbEHDZKg8LnXNugkbwI6Ebyh2AUYTDhzZL2S4aH+C8biHKgSYHFCqieCRhA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -475,7 +474,6 @@
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.19.tgz",
"integrity": "sha512-ETkgDKm0l2PuaBubgPJe0ccy8kE75DFu6/zKcz7TUuk3KrKF2OZAopbbjftsUSZGeCNvCdqHzjmcL6hQ6oAOwA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -489,7 +487,6 @@
"integrity": "sha512-ET/JjO8s62kAHfgIsGXlvW5VUwLqHm03q1y/2yD7aQW/WdDvssMsvZv7Knl440989vdOFemIGTMwVPakmWqRmA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/core": "7.28.3",
"@jridgewell/sourcemap-codec": "^1.4.14",
@@ -522,7 +519,6 @@
"resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.19.tgz",
"integrity": "sha512-SYnwW+q51bQoPtGFoGovm1P5GK9fMEXsG0lGaEAUapjskblAYyX7hLlM/jgueSojv2SjhqNF8aXR+gjHLhZVNA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -566,7 +562,6 @@
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.19.tgz",
"integrity": "sha512-TRZfatH1B/kreDwFRwtpLEurJQ6044qh6DWpvxzTbugaG5otLQJKTk+1z81/KsJwQqc1+24v+yuywc1LM7aq7w==",
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -633,7 +628,6 @@
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -1607,7 +1601,6 @@
"integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@inquirer/checkbox": "^4.2.1",
"@inquirer/confirm": "^5.1.14",
@@ -3538,7 +3531,6 @@
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~7.19.0"
}
@@ -3895,7 +3887,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -4911,7 +4902,6 @@
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -5353,7 +5343,6 @@
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -5850,8 +5839,7 @@
"resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.9.0.tgz",
"integrity": "sha512-OMUvF1iI6+gSRYOhMrH4QYothVLN9C3EJ6wm4g7zLJlnaTl8zbaPOr0bTw70l7QxkoM7sVFOWo83u9B2Fe2Zng==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/jose": {
"version": "6.2.2",
@@ -5953,7 +5941,6 @@
"integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@colors/colors": "1.5.0",
"body-parser": "^1.19.0",
@@ -6421,7 +6408,6 @@
"integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"cli-truncate": "^4.0.0",
"colorette": "^2.0.20",
@@ -7934,7 +7920,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -7970,7 +7955,6 @@
"integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -8590,8 +8574,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD",
- "peer": true
+ "license": "0BSD"
},
"node_modules/tuf-js": {
"version": "4.1.0",
@@ -8629,7 +8612,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -8759,7 +8741,6 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -9557,7 +9538,6 @@
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true,
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -9576,8 +9556,7 @@
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
}
}
}
diff --git a/proxy.conf.json b/proxy.conf.json
index b1b7ec5..1f2ef6a 100644
--- a/proxy.conf.json
+++ b/proxy.conf.json
@@ -1,4 +1,14 @@
{
+ "/api/account": {
+ "target": "http://localhost:8083",
+ "secure": false,
+ "changeOrigin": true
+ },
+ "/api/challenge": {
+ "target": "http://localhost:8083",
+ "secure": false,
+ "changeOrigin": true
+ },
"/api": {
"target": "http://localhost:8080",
"secure": false,
diff --git a/src/app/app.config.ts b/src/app/app.config.ts
index b92a368..77e96f7 100644
--- a/src/app/app.config.ts
+++ b/src/app/app.config.ts
@@ -1,14 +1,15 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
-import { provideHttpClient } from '@angular/common/http';
+import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
+import { authInterceptor } from './services/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
- provideHttpClient(),
+ provideHttpClient(withInterceptors([authInterceptor])),
provideRouter(routes)
]
};
diff --git a/src/app/app.html b/src/app/app.html
index 67e7bd4..515b2b7 100644
--- a/src/app/app.html
+++ b/src/app/app.html
@@ -1 +1,2 @@
-
+
+
\ No newline at end of file
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 215ff45..9fb0ff3 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -1,9 +1,11 @@
import { Routes } from '@angular/router';
import { GameComponent } from './pages/game/game.component';
import { WelcomeComponent } from './pages/welcome/welcome.component';
+import { ProfileComponent } from './pages/profile/profile.component';
export const routes: Routes = [
{ path: '', component: WelcomeComponent },
+ { path: 'profile', component: ProfileComponent },
{ path: 'game/:gameId', component: GameComponent },
{ path: '**', redirectTo: '' }
];
diff --git a/src/app/app.ts b/src/app/app.ts
index 6aba061..4206e4c 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -1,21 +1,18 @@
import { Component, OnInit } from '@angular/core';
import { RouterOutlet } from '@angular/router';
+import { ToolbarComponent } from './components/toolbar/toolbar.component';
+import { ThemeService } from './services/theme.service';
@Component({
selector: 'app-root',
- imports: [RouterOutlet],
+ imports: [RouterOutlet, ToolbarComponent],
templateUrl: './app.html',
styleUrl: './app.css'
})
export class App implements OnInit {
- ngOnInit(): void {
- this.initTheme();
- }
+ constructor(private readonly themeService: ThemeService) { }
- private initTheme(): void {
- const savedTheme = localStorage.getItem('theme');
- if (savedTheme === 'dark') {
- document.documentElement.setAttribute('data-theme', 'dark');
- }
+ ngOnInit(): void {
+ this.themeService.initTheme();
}
}
diff --git a/src/app/button-template.css b/src/app/button-template.css
new file mode 100644
index 0000000..5b8888a
--- /dev/null
+++ b/src/app/button-template.css
@@ -0,0 +1,69 @@
+/* Shared Button Template - All Button Styles Consolidated */
+
+.app-btn {
+ background: var(--btn-bg);
+ color: var(--btn-fg);
+ border: none;
+ border-radius: 2px;
+ padding: 0.6rem 1rem;
+ font-family: 'Space Mono', monospace;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 1px;
+ cursor: pointer;
+ box-shadow: var(--btn-glow);
+ transition: transform 0.2s ease, filter 0.2s ease, background 1.6s ease, box-shadow 1.6s ease;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ outline: none;
+ text-transform: uppercase;
+}
+
+.app-btn:hover:enabled {
+ transform: scale(1.05);
+ filter: brightness(1.15);
+}
+
+.app-btn:active:enabled {
+ transform: scale(0.98);
+}
+
+.app-btn:disabled {
+ opacity: 0.65;
+ cursor: not-allowed;
+ box-shadow: none;
+}
+
+.app-btn.w-100 {
+ width: 100%;
+}
+
+/* Dialog Button Layouts */
+.dialog-actions {
+ display: flex;
+ gap: 0.6rem;
+ flex-wrap: wrap;
+}
+
+.dialog-actions .app-btn {
+ flex: 1;
+ min-width: 120px;
+}
+
+/* Promotion Dialog Button Variant */
+.promotion-choice {
+ flex-direction: column;
+ height: auto;
+ padding: 16px;
+ gap: 8px;
+}
+
+.promotion-choice .piece-symbol {
+ font-size: 32px;
+ line-height: 1;
+}
+
+.promotion-choice .piece-label {
+ font-size: 11px;
+}
diff --git a/src/app/components/chess-board/chess-board.component.css b/src/app/components/chess-board/chess-board.component.css
index a86b1b1..47b3421 100644
--- a/src/app/components/chess-board/chess-board.component.css
+++ b/src/app/components/chess-board/chess-board.component.css
@@ -27,12 +27,56 @@
cursor: pointer;
}
+.square[draggable='true'] {
+ cursor: grab;
+}
+
+.square.drag-source {
+ opacity: 0.65;
+ cursor: grabbing;
+}
+
+.square.drag-over {
+ outline: 3px dashed var(--color-primary);
+ outline-offset: -4px;
+}
+
.square.light {
- background-image: url('/arabian-chess/sprites/board/board_square_white.png');
+ background-image: url('/assets/arabian-chess/sprites/board/board_square_white.png');
}
.square.dark {
- background-image: url('/arabian-chess/sprites/board/board_square_black.png');
+ background-image: url('/assets/arabian-chess/sprites/board/board_square_black.png');
+}
+
+.board-grid--classic {
+ border-radius: var(--border-radius-md);
+}
+
+.board-grid--classic .square {
+ background-image: none;
+ transition: filter 160ms ease;
+}
+
+.board-grid--classic .square.light {
+ background-color: #f3c8a0;
+}
+
+.board-grid--classic .square.dark {
+ background-color: #ba6d4b;
+}
+
+.board-grid--classic .square.drag-over {
+ outline-color: #5a2c28;
+}
+
+.board-grid--classic .square.selected {
+ outline-color: #5a2c28;
+}
+
+.board-grid--classic .square.highlighted::after {
+ background: #b9dad1;
+ border-color: #5a2c28;
}
.square.highlighted::after {
diff --git a/src/app/components/chess-board/chess-board.component.html b/src/app/components/chess-board/chess-board.component.html
index 7bc8e4a..0704d39 100644
--- a/src/app/components/chess-board/chess-board.component.html
+++ b/src/app/components/chess-board/chess-board.component.html
@@ -1,5 +1,5 @@
-
+
@for (square of squares; track trackByCoordinate($index, square)) {
}
-

+ @if (boardTheme === 'arabian') {
+

+ }
diff --git a/src/app/components/chess-board/chess-board.component.ts b/src/app/components/chess-board/chess-board.component.ts
index 179ad63..0a1a1a8 100644
--- a/src/app/components/chess-board/chess-board.component.ts
+++ b/src/app/components/chess-board/chess-board.component.ts
@@ -7,6 +7,8 @@ interface BoardSquare {
pieceCode: string | null;
}
+type BoardTheme = 'arabian' | 'classic';
+
@Component({
selector: 'app-chess-board',
standalone: true,
@@ -18,10 +20,14 @@ export class ChessBoardComponent implements OnChanges {
@Input({ required: true }) fen = '';
@Input() selectedSquare: string | null = null;
@Input() highlightedSquares: string[] = [];
+ @Input() boardTheme: BoardTheme = 'arabian';
@Output() squareSelected = new EventEmitter
();
squares: BoardSquare[] = [];
private highlightedSquareSet = new Set();
+ private draggingFromSquare: string | null = null;
+ private dragOverSquare: string | null = null;
+ private suppressNextClick = false;
ngOnChanges(changes: SimpleChanges): void {
if (changes['fen']) {
@@ -38,9 +44,61 @@ export class ChessBoardComponent implements OnChanges {
}
onSquareClick(square: BoardSquare): void {
+ if (this.suppressNextClick) {
+ this.suppressNextClick = false;
+ return;
+ }
+
this.squareSelected.emit(square.coordinate);
}
+ onPieceDragStart(event: DragEvent, square: BoardSquare): void {
+ if (!square.pieceCode) {
+ event.preventDefault();
+ return;
+ }
+
+ this.draggingFromSquare = square.coordinate;
+ if (event.dataTransfer) {
+ event.dataTransfer.setData('text/plain', square.coordinate);
+ event.dataTransfer.effectAllowed = 'move';
+ }
+ this.squareSelected.emit(square.coordinate);
+ }
+
+ onSquareDragOver(event: DragEvent, square: BoardSquare): void {
+ if (!this.draggingFromSquare) {
+ return;
+ }
+
+ event.preventDefault();
+ if (event.dataTransfer) {
+ event.dataTransfer.dropEffect = 'move';
+ }
+ this.dragOverSquare = square.coordinate === this.draggingFromSquare ? null : square.coordinate;
+ }
+
+ onSquareDrop(event: DragEvent, square: BoardSquare): void {
+ event.preventDefault();
+ if (!this.draggingFromSquare) {
+ return;
+ }
+
+ const fromSquare = this.draggingFromSquare;
+ this.clearDragState();
+
+ if (fromSquare === square.coordinate) {
+ return;
+ }
+
+ this.suppressNextClick = true;
+ this.squareSelected.emit(square.coordinate);
+ }
+
+ onSquareDragEnd(): void {
+ this.clearDragState();
+ }
+
isSelected(square: BoardSquare): boolean {
return this.selectedSquare === square.coordinate;
}
@@ -49,6 +107,14 @@ export class ChessBoardComponent implements OnChanges {
return this.highlightedSquareSet.has(square.coordinate);
}
+ isDraggingSource(square: BoardSquare): boolean {
+ return this.draggingFromSquare === square.coordinate;
+ }
+
+ isDragOver(square: BoardSquare): boolean {
+ return this.dragOverSquare === square.coordinate;
+ }
+
private buildSquares(fen: string): BoardSquare[] {
const placement = fen.split(' ')[0] ?? '';
const rows = placement.split('/');
@@ -87,4 +153,9 @@ export class ChessBoardComponent implements OnChanges {
pieceCode
};
}
+
+ private clearDragState(): void {
+ this.draggingFromSquare = null;
+ this.dragOverSquare = null;
+ }
}
diff --git a/src/app/components/chess-piece/chess-piece.component.css b/src/app/components/chess-piece/chess-piece.component.css
index 4a364f3..f876000 100644
--- a/src/app/components/chess-piece/chess-piece.component.css
+++ b/src/app/components/chess-piece/chess-piece.component.css
@@ -3,7 +3,15 @@
height: clamp(40px, 8cqh, 120px);
display: block;
object-fit: contain;
- pointer-events: none;
+ pointer-events: auto;
+}
+
+.piece[draggable='true'] {
+ cursor: grab;
+}
+
+.piece[draggable='true']:active {
+ cursor: grabbing;
}
@media (max-width: 991px) {
diff --git a/src/app/components/chess-piece/chess-piece.component.html b/src/app/components/chess-piece/chess-piece.component.html
index 42766a1..7d085f4 100644
--- a/src/app/components/chess-piece/chess-piece.component.html
+++ b/src/app/components/chess-piece/chess-piece.component.html
@@ -1,3 +1,10 @@
@if (pieceCode) {
-
+
}
diff --git a/src/app/components/chess-piece/chess-piece.component.ts b/src/app/components/chess-piece/chess-piece.component.ts
index 2804978..6b3e125 100644
--- a/src/app/components/chess-piece/chess-piece.component.ts
+++ b/src/app/components/chess-piece/chess-piece.component.ts
@@ -1,4 +1,6 @@
-import { Component, Input } from '@angular/core';
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+
+type BoardTheme = 'arabian' | 'classic';
@Component({
selector: 'app-chess-piece',
@@ -8,18 +10,44 @@ import { Component, Input } from '@angular/core';
})
export class ChessPieceComponent {
@Input({ required: true }) pieceCode: string | null = null;
+ @Input() boardTheme: BoardTheme = 'arabian';
+ @Input() draggable = false;
+ @Output() pieceDragStart = new EventEmitter();
+ @Output() pieceDragEnd = new EventEmitter();
+
+ onDragStart(event: DragEvent): void {
+ if (!this.draggable) {
+ event.preventDefault();
+ return;
+ }
+
+ this.pieceDragStart.emit(event);
+ }
+
+ onDragEnd(): void {
+ this.pieceDragEnd.emit();
+ }
get spriteUrl(): string {
if (!this.pieceCode) {
return '';
}
- const color = this.pieceCode === this.pieceCode.toUpperCase() ? 'white' : 'black';
- const pieceName = this.getPieceName(this.pieceCode.toLowerCase());
- return `/arabian-chess/sprites/pieces/${color}_${pieceName}.png`;
+ const isWhite = this.pieceCode === this.pieceCode.toUpperCase();
+ const pieceCode = this.pieceCode.toLowerCase();
+
+ if (this.boardTheme === 'classic') {
+ const colorPrefix = isWhite ? 'w' : 'b';
+ const classicPieceName = this.getClassicPieceName(pieceCode);
+ return `/assets/ChessAssets/${colorPrefix}_${classicPieceName}.png`;
+ }
+
+ const arabianColor = isWhite ? 'white' : 'black';
+ const arabianPieceName = this.getArabianPieceName(pieceCode);
+ return `/assets/arabian-chess/sprites/pieces/${arabianColor}_${arabianPieceName}.png`;
}
- private getPieceName(piece: string): string {
+ private getArabianPieceName(piece: string): string {
switch (piece) {
case 'k':
return 'king';
@@ -37,4 +65,23 @@ export class ChessPieceComponent {
return 'pawn';
}
}
+
+ private getClassicPieceName(piece: string): string {
+ switch (piece) {
+ case 'k':
+ return 'King';
+ case 'q':
+ return 'Queen';
+ case 'r':
+ return 'Rook';
+ case 'b':
+ return 'Bishop';
+ case 'n':
+ return 'Knight';
+ case 'p':
+ return 'Pawn';
+ default:
+ return 'Pawn';
+ }
+ }
}
diff --git a/src/app/components/input-card/input-card.component.css b/src/app/components/input-card/input-card.component.css
index b4cb7a4..65aa86b 100644
--- a/src/app/components/input-card/input-card.component.css
+++ b/src/app/components/input-card/input-card.component.css
@@ -1,3 +1,5 @@
+@import '../../button-template.css';
+
.input-card {
background: var(--color-bg-card);
border: var(--border-width) solid var(--color-border);
@@ -40,22 +42,6 @@
}
-.input-card .btn {
- border: var(--button-border);
- border-radius: var(--border-radius-sm);
- background: var(--color-bg-button);
- color: var(--color-text-primary);
- padding: var(--button-padding);
- cursor: pointer;
- font-weight: 600;
- transition: background-color 0.2s, color 0.2s;
-}
-
-.input-card .btn:hover {
- background: var(--color-bg-button-hover);
- color: var(--color-text-button-hover);
-}
-
.hint-text {
margin: 0;
color: var(--color-text-primary);
diff --git a/src/app/components/input-card/input-card.component.html b/src/app/components/input-card/input-card.component.html
index 34b9d1a..bdc6202 100644
--- a/src/app/components/input-card/input-card.component.html
+++ b/src/app/components/input-card/input-card.component.html
@@ -2,30 +2,18 @@
@if (inputType === 'textarea') {
-
+
} @else {
-
+
}
-
+
\ No newline at end of file
diff --git a/src/app/components/register-dialog/register-dialog.component.css b/src/app/components/register-dialog/register-dialog.component.css
new file mode 100644
index 0000000..acea1e8
--- /dev/null
+++ b/src/app/components/register-dialog/register-dialog.component.css
@@ -0,0 +1,68 @@
+@import '../../button-template.css';
+
+.dialog-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(2, 2, 10, 0.58);
+ display: grid;
+ place-items: center;
+ z-index: 350;
+ padding: 1rem;
+}
+
+.dialog-card {
+ width: min(460px, 100%);
+ background: var(--dlg-bg);
+ border: 1.5px solid var(--dlg-border);
+ box-shadow: var(--bb-glow);
+ border-radius: 4px;
+ padding: 1rem;
+ display: grid;
+ gap: 0.7rem;
+}
+
+.dialog-title {
+ font-family: 'Bebas Neue', sans-serif;
+ font-size: 22px;
+ letter-spacing: 2px;
+ color: var(--bb-title);
+ text-align: center;
+}
+
+.dialog-input {
+ width: 100%;
+ background: rgba(4, 4, 20, 0.62);
+ border: 1px solid var(--bb-border);
+ color: var(--bb-title);
+ border-radius: 2px;
+ padding: 0.6rem 0.7rem;
+ font-family: 'Space Mono', monospace;
+ font-size: 13px;
+}
+
+.dialog-input:focus {
+ outline: none;
+ box-shadow: 0 0 0 2px rgba(0, 213, 255, 0.25);
+}
+
+.dialog-textarea {
+ min-height: 120px;
+ resize: vertical;
+}
+
+.text-danger {
+ color: #dc3545;
+ font-size: 0.875rem;
+}
+
+.sr-only {
+ position: absolute !important;
+ width: 1px !important;
+ height: 1px !important;
+ padding: 0 !important;
+ margin: -1px !important;
+ overflow: hidden !important;
+ clip: rect(0, 0, 0, 0) !important;
+ white-space: nowrap !important;
+ border: 0 !important;
+}
diff --git a/src/app/components/register-dialog/register-dialog.component.html b/src/app/components/register-dialog/register-dialog.component.html
new file mode 100644
index 0000000..6562df8
--- /dev/null
+++ b/src/app/components/register-dialog/register-dialog.component.html
@@ -0,0 +1,43 @@
+
\ No newline at end of file
diff --git a/src/app/components/register-dialog/register-dialog.component.ts b/src/app/components/register-dialog/register-dialog.component.ts
new file mode 100644
index 0000000..ef11842
--- /dev/null
+++ b/src/app/components/register-dialog/register-dialog.component.ts
@@ -0,0 +1,71 @@
+import { Component, EventEmitter, Output, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
+import { AuthService } from '../../services/auth.service';
+import { AuthDialogService } from '../../services/auth-dialog.service';
+
+@Component({
+ selector: 'app-register-dialog',
+ standalone: true,
+ imports: [CommonModule, ReactiveFormsModule],
+ templateUrl: './register-dialog.component.html',
+ styleUrl: './register-dialog.component.css'
+})
+export class RegisterDialogComponent {
+ @Output() onClose = new EventEmitter();
+ @Output() onSuccess = new EventEmitter();
+
+ private readonly authService = inject(AuthService);
+ private readonly authDialogService = inject(AuthDialogService);
+ private readonly formBuilder = inject(FormBuilder);
+
+ registerForm: FormGroup;
+ errorMessage: string | null = null;
+ isLoading = false;
+
+ constructor() {
+ this.registerForm = this.formBuilder.group({
+ username: ['', [Validators.required, Validators.minLength(3)]],
+ email: ['', [Validators.required, Validators.email]],
+ password: ['', [Validators.required, Validators.minLength(6)]],
+ confirmPassword: ['', [Validators.required]]
+ });
+ }
+
+ onSubmit(): void {
+ if (this.registerForm.invalid) {
+ this.errorMessage = 'Please fill in all fields correctly';
+ return;
+ }
+
+ const { password, confirmPassword } = this.registerForm.value;
+ if (password !== confirmPassword) {
+ this.errorMessage = 'Passwords do not match';
+ return;
+ }
+
+ this.isLoading = true;
+ this.errorMessage = null;
+
+ const { username, email, password: pwd } = this.registerForm.value;
+ this.authService.register(username, pwd, email).subscribe({
+ next: () => {
+ this.isLoading = false;
+ this.onSuccess.emit();
+ },
+ error: (err) => {
+ this.isLoading = false;
+ this.errorMessage =
+ err.error?.message || 'Registration failed. Please try again.';
+ }
+ });
+ }
+
+ closeDialog(): void {
+ this.onClose.emit();
+ }
+
+ openLogin(): void {
+ this.authDialogService.openLogin();
+ }
+}
diff --git a/src/app/components/toolbar/toolbar.component.css b/src/app/components/toolbar/toolbar.component.css
new file mode 100644
index 0000000..be8f759
--- /dev/null
+++ b/src/app/components/toolbar/toolbar.component.css
@@ -0,0 +1,84 @@
+@import '../../button-template.css';
+
+.navbar {
+ background: rgba(8, 6, 28, 0.85);
+ backdrop-filter: blur(8px);
+ box-shadow: 0 4px 20px rgba(0, 210, 255, 0.15);
+ border-bottom: 1px solid rgba(0, 210, 255, 0.2);
+ border-radius: 0;
+ padding: 0.75rem 1rem;
+}
+
+.navbar-brand {
+ font-size: 1.5rem;
+ font-weight: bold;
+ color: var(--bb-title) !important;
+ font-family: 'Bebas Neue', sans-serif;
+ letter-spacing: 1px;
+ cursor: pointer;
+}
+
+.gap-2 {
+ gap: 0.5rem;
+}
+
+.user-section {
+ align-items: center;
+}
+
+.me-btn {
+ background: rgba(0, 210, 255, 0.1);
+ color: var(--bb-title);
+ border: 1px solid var(--bb-border);
+ border-radius: 2px;
+ padding: 0.5rem 0.8rem;
+ font-family: 'Space Mono', monospace;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.5px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ outline: none;
+ text-transform: uppercase;
+}
+
+.me-btn:hover {
+ background: rgba(0, 210, 255, 0.2);
+ border-color: var(--bb-tag);
+ box-shadow: 0 0 10px rgba(0, 210, 255, 0.4);
+ transform: scale(1.05);
+}
+
+.me-btn:active {
+ transform: scale(0.98);
+}
+
+/* Sunset Mode */
+.sunset .navbar {
+ background: rgba(20, 5, 45, 0.85);
+ border-bottom-color: rgba(255, 64, 207, 0.2);
+ box-shadow: 0 4px 20px rgba(242, 106, 226, 0.15);
+}
+
+.sunset .me-btn {
+ background: rgba(242, 106, 226, 0.1);
+ border-color: var(--bb-border);
+}
+
+.sunset .me-btn:hover {
+ background: rgba(242, 106, 226, 0.2);
+ border-color: var(--bb-tag);
+ box-shadow: 0 0 10px rgba(242, 106, 226, 0.4);
+}
+
+.container-fluid {
+ display: flex;
+ align-items: center;
+}
+
+.ms-auto {
+ margin-left: auto;
+}
diff --git a/src/app/components/toolbar/toolbar.component.html b/src/app/components/toolbar/toolbar.component.html
new file mode 100644
index 0000000..cba57a3
--- /dev/null
+++ b/src/app/components/toolbar/toolbar.component.html
@@ -0,0 +1,35 @@
+
+
+@if (showLoginDialog) {
+
+}
+
+@if (showRegisterDialog) {
+
+}
\ No newline at end of file
diff --git a/src/app/components/toolbar/toolbar.component.ts b/src/app/components/toolbar/toolbar.component.ts
new file mode 100644
index 0000000..bc4a815
--- /dev/null
+++ b/src/app/components/toolbar/toolbar.component.ts
@@ -0,0 +1,87 @@
+import { Component, DestroyRef, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { Router } from '@angular/router';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { AuthService } from '../../services/auth.service';
+import { AuthDialogService } from '../../services/auth-dialog.service';
+import { CurrentUser } from '../../models/auth.models';
+import { LoginDialogComponent } from '../login-dialog/login-dialog.component';
+import { RegisterDialogComponent } from '../register-dialog/register-dialog.component';
+import { ThemeService } from '../../services/theme.service';
+
+@Component({
+ selector: 'app-toolbar',
+ standalone: true,
+ imports: [CommonModule, LoginDialogComponent, RegisterDialogComponent],
+ templateUrl: './toolbar.component.html',
+ styleUrl: './toolbar.component.css'
+})
+export class ToolbarComponent implements OnInit {
+ private readonly destroyRef = inject(DestroyRef);
+ private readonly authService = inject(AuthService);
+ private readonly authDialogService = inject(AuthDialogService);
+ private readonly themeService = inject(ThemeService);
+ private readonly router = inject(Router);
+
+ currentUser: CurrentUser | null = null;
+ showLoginDialog = false;
+ showRegisterDialog = false;
+ isDarkMode = false;
+
+ ngOnInit(): void {
+ this.authService.currentUser$
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((user) => {
+ this.currentUser = user;
+ });
+
+ this.authDialogService.dialogState$
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((state) => {
+ this.showLoginDialog = state === 'login';
+ this.showRegisterDialog = state === 'register';
+ });
+
+ this.themeService.darkMode$
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((isDarkMode) => {
+ this.isDarkMode = isDarkMode;
+ });
+ }
+
+ openLoginDialog(): void {
+ this.authDialogService.openLogin();
+ }
+
+ closeLoginDialog(): void {
+ this.authDialogService.close();
+ }
+
+ openRegisterDialog(): void {
+ this.authDialogService.openRegister();
+ }
+
+ closeRegisterDialog(): void {
+ this.authDialogService.close();
+ }
+
+ logout(): void {
+ this.authService.logout();
+ }
+
+ toggleTheme(): void {
+ this.themeService.toggleTheme();
+ }
+
+ goToProfile(): void {
+ this.router.navigate(['/profile']);
+ }
+
+ onLoginSuccess(): void {
+ this.closeLoginDialog();
+ }
+
+ onRegisterSuccess(): void {
+ this.closeRegisterDialog();
+ }
+}
diff --git a/src/app/models/auth.models.ts b/src/app/models/auth.models.ts
new file mode 100644
index 0000000..0af910d
--- /dev/null
+++ b/src/app/models/auth.models.ts
@@ -0,0 +1,28 @@
+export interface LoginRequest {
+ username: string;
+ password: string;
+}
+
+export interface RegisterRequest {
+ username: string;
+ password: string;
+ email?: string;
+}
+
+export interface RegisterResponse {
+ id: string;
+ username: string;
+ rating: number;
+ createdAt: string;
+}
+
+export interface LoginResponse {
+ token: string;
+}
+
+export interface CurrentUser {
+ id: string;
+ username: string;
+ rating: number;
+ createdAt: string;
+}
diff --git a/src/app/pages/game/game.component.css b/src/app/pages/game/game.component.css
index 042bcef..130e475 100644
--- a/src/app/pages/game/game.component.css
+++ b/src/app/pages/game/game.component.css
@@ -1,6 +1,16 @@
+@import '../../button-template.css';
+
.game-shell {
min-height: 100dvh;
padding: clamp(var(--size-md), 2vw, var(--size-xl));
+ background: linear-gradient(180deg, var(--color-primary-light) 0%, var(--color-secondary-mint) 100%);
+ color: var(--color-text-primary);
+}
+
+:host-context(html[data-theme='dark']) .game-shell {
+ background:
+ radial-gradient(circle at top, rgba(185, 194, 218, 0.16) 0%, transparent 35%),
+ linear-gradient(180deg, #0f1f2e 0%, #17293d 52%, #0b1420 100%);
}
.game-card {
@@ -13,8 +23,14 @@
box-shadow: var(--shadow-md);
}
+:host-context(html[data-theme='dark']) .game-shell .game-card {
+ background: rgba(26, 47, 71, 0.88);
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.34);
+}
+
header {
margin-bottom: var(--size-xl);
+ margin-bottom: var(--size-xl);
}
h1,
@@ -22,9 +38,13 @@ h2 {
color: var(--color-text-primary);
margin: 0 0 var(--size-md);
font-size: var(--heading-h1);
+ color: var(--color-text-primary);
+ margin: 0 0 var(--size-md);
+ font-size: var(--heading-h1);
}
.meta {
+ color: var(--color-text-primary);
color: var(--color-text-primary);
font-size: 0.95rem;
}
@@ -33,6 +53,8 @@ h2 {
display: inline-block;
margin-bottom: var(--size-sm);
color: var(--color-text-primary);
+ margin-bottom: var(--size-sm);
+ color: var(--color-text-primary);
text-decoration: none;
font-weight: 600;
}
@@ -48,6 +70,39 @@ h2 {
flex: 0 0 auto;
}
+.board-theme-card {
+ background: var(--color-bg-card);
+ border: var(--border-width) solid var(--color-border);
+ border-radius: var(--border-radius-md);
+ padding: var(--size-md-padding);
+ display: grid;
+ gap: var(--size-sm);
+}
+
+.board-theme-card h3 {
+ margin: 0;
+ color: var(--color-text-primary);
+ font-size: 1rem;
+}
+
+.board-theme-group {
+ display: flex;
+ gap: var(--size-md);
+ flex-wrap: wrap;
+}
+
+.board-theme-option {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--size-xs);
+ color: var(--color-text-primary);
+ font-weight: 600;
+}
+
+.board-theme-option input {
+ accent-color: var(--color-primary);
+}
+
.move-card {
padding: var(--size-lg-padding);
}
@@ -62,6 +117,10 @@ h2 {
}
.board-section {
+ background: var(--color-bg-board);
+ border: var(--border-width) solid var(--color-border);
+ border-radius: var(--border-radius-md);
+ padding: clamp(var(--size-sm), 1vw, var(--size-lg));
background: var(--color-bg-board);
border: var(--border-width) solid var(--color-border);
border-radius: var(--border-radius-md);
@@ -70,6 +129,148 @@ h2 {
container-type: size;
}
+:host-context(html[data-theme='dark']) .game-shell .board-section,
+:host-context(html[data-theme='dark']) .game-shell .timer-card,
+:host-context(html[data-theme='dark']) .game-shell .history-card,
+:host-context(html[data-theme='dark']) .game-shell .export-card,
+:host-context(html[data-theme='dark']) .game-shell .board-theme-card,
+:host-context(html[data-theme='dark']) .game-shell .player-timer {
+ background: rgba(45, 74, 111, 0.72);
+}
+
+:host-context(html[data-theme='dark']) .game-shell .export-text {
+ background: rgba(26, 47, 71, 0.9);
+}
+
+:host-context(html[data-theme='dark']) .game-shell .game-completion-alert {
+ background: linear-gradient(135deg, rgba(74, 124, 124, 0.35) 0%, rgba(90, 111, 165, 0.35) 100%);
+ box-shadow: 0 10px 20px rgba(0, 0, 0, 0.25);
+}
+
+.timer-card,
+.history-card,
+.export-card {
+ background: var(--color-bg-card);
+ border: var(--border-width) solid var(--color-border);
+ border-radius: var(--border-radius-md);
+ padding: var(--size-lg-padding);
+ display: grid;
+ gap: var(--size-md);
+}
+
+.timer-card h2,
+.history-card h2,
+.export-card h2 {
+ margin: 0;
+ font-size: 1.1rem;
+ color: var(--color-text-primary);
+}
+
+.history-list {
+ margin: 0;
+ padding-left: 1.1rem;
+ display: grid;
+ gap: var(--size-xs);
+ max-height: 180px;
+ overflow: auto;
+}
+
+.history-list li {
+ color: var(--color-text-primary);
+ display: flex;
+ gap: var(--size-sm);
+ align-items: baseline;
+}
+
+.history-number {
+ font-weight: 700;
+ min-width: 1.8rem;
+}
+
+.history-move {
+ font-family: monospace;
+}
+
+.history-empty {
+ margin: 0;
+ color: var(--color-text-primary);
+}
+
+.player-timer {
+ background: var(--color-bg-input);
+ border: var(--border-width) solid var(--color-border);
+ border-radius: var(--border-radius-md);
+ padding: var(--size-md-padding);
+}
+
+.active-timer {
+ box-shadow: 0 0 0 3px rgba(185, 218, 209, 0.25);
+}
+
+.timer-label {
+ margin: 0;
+ color: var(--color-text-primary);
+ font-weight: 600;
+}
+
+.timer-value {
+ margin: var(--size-xs) 0 0;
+ color: var(--color-text-primary);
+ font-size: 1.35rem;
+ font-weight: 700;
+}
+
+.export-mode-group {
+ display: flex;
+ gap: var(--size-lg);
+ flex-wrap: wrap;
+}
+
+.export-mode-option {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--size-sm);
+ color: var(--color-text-primary);
+ font-weight: 600;
+}
+
+.export-mode-option input {
+ accent-color: var(--color-primary);
+}
+
+.export-text {
+ width: 100%;
+ min-height: 140px;
+ border: var(--border-width) solid var(--color-border);
+ border-radius: var(--border-radius-md);
+ background: var(--color-bg-input);
+ color: var(--color-text-primary);
+ padding: var(--size-md-padding);
+ resize: vertical;
+}
+
+.export-button {
+ width: fit-content;
+ border: var(--button-border);
+ border-radius: var(--button-radius);
+ background: var(--color-bg-button);
+ color: var(--color-text-primary);
+ font-weight: 700;
+ padding: var(--button-padding);
+ cursor: pointer;
+}
+
+.export-button:hover {
+ background: var(--color-bg-button-hover);
+ color: var(--color-text-button-hover);
+}
+
+.export-note {
+ margin: 0;
+ color: var(--color-text-primary);
+ font-weight: 600;
+}
+
.alert {
border-radius: var(--border-radius-sm);
border: var(--border-width) solid var(--color-border);
@@ -126,6 +327,7 @@ h2 {
@media (max-width: 991px) {
.game-card {
padding: clamp(var(--size-md), 1.5vw, var(--size-lg));
+ padding: clamp(var(--size-md), 1.5vw, var(--size-lg));
}
.board-section {
@@ -135,25 +337,30 @@ h2 {
h1,
h2 {
font-size: var(--heading-h1-tablet);
+ font-size: var(--heading-h1-tablet);
}
}
@media (max-width: 768px) {
.game-shell {
padding: clamp(var(--size-sm), 1.5vw, var(--size-lg));
+ padding: clamp(var(--size-sm), 1.5vw, var(--size-lg));
}
.game-card {
padding: clamp(var(--size-sm), 1vw, var(--size-md));
+ padding: clamp(var(--size-sm), 1vw, var(--size-md));
}
header {
margin-bottom: var(--size-lg);
+ margin-bottom: var(--size-lg);
}
h1,
h2 {
font-size: var(--heading-h1-mobile);
+ font-size: var(--heading-h1-mobile);
}
.meta {
@@ -163,6 +370,8 @@ h2 {
.top-section {
gap: var(--size-xs);
margin-bottom: var(--size-xs);
+ gap: var(--size-xs);
+ margin-bottom: var(--size-xs);
}
.board-section {
@@ -173,19 +382,24 @@ h2 {
@media (max-width: 480px) {
.game-shell {
padding: var(--size-sm);
+ padding: var(--size-sm);
}
.game-card {
padding: var(--size-sm);
border-radius: var(--border-radius-md);
+ padding: var(--size-sm);
+ border-radius: var(--border-radius-md);
}
header {
margin-bottom: var(--size-md);
+ margin-bottom: var(--size-md);
}
h1 {
font-size: var(--heading-h1-small);
+ font-size: var(--heading-h1-small);
}
.meta {
@@ -195,6 +409,8 @@ h2 {
.top-section {
gap: var(--size-xs-gap);
margin-bottom: var(--size-xs);
+ gap: var(--size-xs-gap);
+ margin-bottom: var(--size-xs);
}
.board-section {
diff --git a/src/app/pages/game/game.component.html b/src/app/pages/game/game.component.html
index 04c93b2..dac651b 100644
--- a/src/app/pages/game/game.component.html
+++ b/src/app/pages/game/game.component.html
@@ -1,9 +1,6 @@
-
-
+
+
@if (facade.loading) {
- Loading game state...
+ Loading game state...
} @else if (facade.state) {
- @if (facade.isGameFinished && facade.gameCompletionMessage) {
-
-
{{ facade.gameCompletionMessage }}
-
- Start a new game
-
+ @if (facade.isGameFinished && facade.gameCompletionMessage) {
+
+ }
+ @if (facade.isGameFinished && facade.gameCompletionMessage) {
+
+ }
+
+
+
+
+
+ Timers
+
+
White
+
{{ formatTimer(whiteTimerSeconds) }}
+
+
+
Black
+
{{ formatTimer(blackTimerSeconds) }}
+
+
- }
-
-
-
-
-
-
+
+
+
+
+ Move History
+
+ @if (facade.state.moves.length === 0) {
+ No moves yet.
+ } @else {
+
+ @for (move of facade.state.moves; track $index) {
+ -
+ {{ $index + 1 }}.
+ {{ move }}
+
+ }
+
+ }
+
+
+
+
}
@if (facade.errorMessage) {
-
{{ facade.errorMessage }}
+
{{ facade.errorMessage }}
}
-
+
\ No newline at end of file
diff --git a/src/app/pages/game/game.component.ts b/src/app/pages/game/game.component.ts
index 307aed4..a7c14fb 100644
--- a/src/app/pages/game/game.component.ts
+++ b/src/app/pages/game/game.component.ts
@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
-import { Component, DestroyRef, OnInit, inject } from '@angular/core';
+import { Component, DestroyRef, OnDestroy, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
@@ -8,6 +8,16 @@ import { InputCardComponent } from '../../components/input-card/input-card.compo
import { PromotionDialogComponent } from '../../components/promotion-dialog/promotion-dialog.component';
import { GameFacade } from './game.facade';
+type TimerTurn = 'white' | 'black';
+type BoardTheme = 'arabian' | 'classic';
+
+interface TimerSnapshot {
+ whiteSeconds: number;
+ blackSeconds: number;
+ turn: TimerTurn;
+ savedAt: number;
+}
+
@Component({
selector: 'app-game',
standalone: true,
@@ -16,12 +26,28 @@ import { GameFacade } from './game.facade';
templateUrl: './game.component.html',
styleUrl: './game.component.css'
})
-export class GameComponent implements OnInit {
+export class GameComponent implements OnInit, OnDestroy {
+ private static readonly TIMER_START_SECONDS = 10 * 60;
+ private static readonly BOARD_THEME_STORAGE_KEY = 'nowchess.boardTheme';
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
readonly facade = inject(GameFacade);
+ whiteTimerSeconds = GameComponent.TIMER_START_SECONDS;
+ blackTimerSeconds = GameComponent.TIMER_START_SECONDS;
+ exportType: 'fen' | 'pgn' = 'fen';
+ boardTheme: BoardTheme = 'arabian';
+ isDarkMode = false;
+ exportValue = '';
+ exportNotice = '';
+ private timerIntervalId: number | null = null;
+ private activeGameId = '';
ngOnInit(): void {
+ this.applyIncomingTheme();
+ this.syncThemeFromDocument();
+ this.boardTheme = this.resolveStoredBoardTheme();
+ this.startDummyTimers();
+
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((paramMap) => {
const id = paramMap.get('gameId');
if (!id) {
@@ -30,7 +56,203 @@ export class GameComponent implements OnInit {
return;
}
+ this.activeGameId = id;
+ this.restoreTimers(id);
this.facade.setGameId(id);
+ this.syncExportValue();
});
}
+
+ ngOnDestroy(): void {
+ if (this.timerIntervalId !== null) {
+ window.clearInterval(this.timerIntervalId);
+ }
+
+ this.persistTimers(this.resolveCurrentTurn());
+ }
+
+ private syncThemeFromDocument(): void {
+ this.isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark';
+ }
+
+ private applyIncomingTheme(): void {
+ const incomingTheme = window.history.state?.theme;
+ if (incomingTheme === 'dark') {
+ document.documentElement.setAttribute('data-theme', 'dark');
+ localStorage.setItem('theme', 'dark');
+ return;
+ }
+
+ if (incomingTheme === 'light') {
+ document.documentElement.removeAttribute('data-theme');
+ localStorage.removeItem('theme');
+ }
+ }
+
+ setExportType(type: 'fen' | 'pgn'): void {
+ this.exportType = type;
+ this.exportNotice = '';
+ this.syncExportValue();
+ }
+
+ setBoardTheme(theme: BoardTheme): void {
+ this.boardTheme = theme;
+ localStorage.setItem(GameComponent.BOARD_THEME_STORAGE_KEY, theme);
+ }
+
+ completeExport(): void {
+ this.syncExportValue();
+ if (!this.exportValue.trim()) {
+ this.exportNotice = 'Nothing to export yet.';
+ return;
+ }
+
+ if (!navigator.clipboard?.writeText) {
+ this.exportNotice = 'Export is ready in the text box.';
+ return;
+ }
+
+ void navigator.clipboard
+ .writeText(this.exportValue)
+ .then(() => {
+ this.exportNotice = 'Copied to clipboard.';
+ })
+ .catch(() => {
+ this.exportNotice = 'Export is ready in the text box.';
+ });
+ }
+
+ formatTimer(totalSeconds: number): string {
+ const safeSeconds = Math.max(0, totalSeconds);
+ const minutes = Math.floor(safeSeconds / 60)
+ .toString()
+ .padStart(2, '0');
+ const seconds = (safeSeconds % 60).toString().padStart(2, '0');
+ return `${minutes}:${seconds}`;
+ }
+
+ private startDummyTimers(): void {
+ if (this.timerIntervalId !== null) {
+ return;
+ }
+
+ this.timerIntervalId = window.setInterval(() => {
+ this.tickDummyTimers();
+ this.syncExportValue();
+ }, 1000);
+ }
+
+ private tickDummyTimers(): void {
+ const state = this.facade.state;
+ if (!state || this.facade.loading || this.facade.isGameFinished) {
+ return;
+ }
+
+ if (state.turn === 'white') {
+ this.whiteTimerSeconds = Math.max(0, this.whiteTimerSeconds - 1);
+ this.persistTimers('white');
+ return;
+ }
+
+ this.blackTimerSeconds = Math.max(0, this.blackTimerSeconds - 1);
+ this.persistTimers('black');
+ }
+
+ private syncExportValue(): void {
+ const state = this.facade.state;
+ if (!state) {
+ this.exportValue = '';
+ return;
+ }
+
+ this.exportValue = this.exportType === 'fen' ? state.fen : state.pgn;
+ }
+
+ private restoreTimers(gameId: string): void {
+ const fallbackTurn = this.resolveCurrentTurn();
+ const rawSnapshot = localStorage.getItem(this.getTimerStorageKey(gameId));
+ if (!rawSnapshot) {
+ this.resetTimers();
+ this.persistTimers(fallbackTurn);
+ return;
+ }
+
+ const snapshot = this.parseSnapshot(rawSnapshot);
+ if (!snapshot) {
+ this.resetTimers();
+ this.persistTimers(fallbackTurn);
+ return;
+ }
+
+ this.applySnapshot(snapshot);
+ this.persistTimers(snapshot.turn);
+ }
+
+ private parseSnapshot(rawSnapshot: string): TimerSnapshot | null {
+ try {
+ const parsed = JSON.parse(rawSnapshot) as Partial
;
+ if (
+ typeof parsed.whiteSeconds !== 'number' ||
+ typeof parsed.blackSeconds !== 'number' ||
+ (parsed.turn !== 'white' && parsed.turn !== 'black') ||
+ typeof parsed.savedAt !== 'number'
+ ) {
+ return null;
+ }
+
+ return {
+ whiteSeconds: Math.max(0, Math.floor(parsed.whiteSeconds)),
+ blackSeconds: Math.max(0, Math.floor(parsed.blackSeconds)),
+ turn: parsed.turn,
+ savedAt: parsed.savedAt
+ };
+ } catch {
+ return null;
+ }
+ }
+
+ private applySnapshot(snapshot: TimerSnapshot): void {
+ const elapsedSeconds = Math.max(0, Math.floor((Date.now() - snapshot.savedAt) / 1000));
+ this.whiteTimerSeconds = snapshot.whiteSeconds;
+ this.blackTimerSeconds = snapshot.blackSeconds;
+
+ if (snapshot.turn === 'white') {
+ this.whiteTimerSeconds = Math.max(0, this.whiteTimerSeconds - elapsedSeconds);
+ return;
+ }
+
+ this.blackTimerSeconds = Math.max(0, this.blackTimerSeconds - elapsedSeconds);
+ }
+
+ private persistTimers(turn: TimerTurn): void {
+ if (!this.activeGameId) {
+ return;
+ }
+
+ const snapshot: TimerSnapshot = {
+ whiteSeconds: this.whiteTimerSeconds,
+ blackSeconds: this.blackTimerSeconds,
+ turn,
+ savedAt: Date.now()
+ };
+ localStorage.setItem(this.getTimerStorageKey(this.activeGameId), JSON.stringify(snapshot));
+ }
+
+ private resolveCurrentTurn(): TimerTurn {
+ return this.facade.state?.turn ?? 'white';
+ }
+
+ private resetTimers(): void {
+ this.whiteTimerSeconds = GameComponent.TIMER_START_SECONDS;
+ this.blackTimerSeconds = GameComponent.TIMER_START_SECONDS;
+ }
+
+ private getTimerStorageKey(gameId: string): string {
+ return `nowchess.timer.${gameId}`;
+ }
+
+ private resolveStoredBoardTheme(): BoardTheme {
+ const stored = localStorage.getItem(GameComponent.BOARD_THEME_STORAGE_KEY);
+ return stored === 'classic' ? 'classic' : 'arabian';
+ }
}
diff --git a/src/app/pages/profile/profile.component.css b/src/app/pages/profile/profile.component.css
new file mode 100644
index 0000000..595c49e
--- /dev/null
+++ b/src/app/pages/profile/profile.component.css
@@ -0,0 +1,478 @@
+@import '../welcome/welcome.component.css';
+
+.profile-building-container {
+ position: absolute;
+ bottom: 10%;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 15;
+}
+
+.building-wrapper {
+ position: relative;
+ width: clamp(300px, 80vw, 600px);
+ margin: 0 auto;
+}
+
+.building-structure {
+ background: linear-gradient(135deg, var(--bldg-body) 0%, var(--bldg-mid) 50%, var(--bldg-lit) 100%);
+ border: 2px solid var(--dlg-border);
+ border-radius: 8px;
+ box-shadow: var(--bb-glow), inset 0 0 20px rgba(0, 210, 255, 0.1);
+ overflow: hidden;
+}
+
+.cityscape-shell.sunset .building-structure {
+ box-shadow: var(--bb-glow), inset 0 0 20px rgba(255, 120, 40, 0.1);
+}
+
+.building-top {
+ height: 30px;
+ background: linear-gradient(180deg, var(--bldg-mid) 0%, var(--bldg-body) 100%);
+ border-bottom: 1px solid rgba(0, 210, 255, 0.3);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: bold;
+ color: var(--bb-tag);
+ font-family: 'Bebas Neue', sans-serif;
+ letter-spacing: 2px;
+ position: relative;
+}
+
+.cityscape-shell.sunset .building-top {
+ border-bottom-color: rgba(255, 120, 40, 0.3);
+}
+
+.building-top::after {
+ content: 'MY PROFILE';
+ font-size: 12px;
+}
+
+.building-main {
+ display: grid;
+ grid-template-columns: 80px 1fr 80px;
+ gap: 0;
+ padding: 20px;
+ min-height: 200px;
+ background: linear-gradient(90deg, rgba(0, 210, 255, 0.03) 0%, transparent 15%, transparent 85%, rgba(0, 210, 255, 0.03) 100%);
+ align-items: center;
+}
+
+.cityscape-shell.sunset .building-main {
+ background: linear-gradient(90deg, rgba(255, 120, 40, 0.05) 0%, transparent 15%, transparent 85%, rgba(255, 120, 40, 0.05) 100%);
+}
+
+.building-side {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ justify-content: center;
+}
+
+.building-side.left-side {
+ border-right: 1px solid rgba(0, 210, 255, 0.2);
+ padding-right: 15px;
+}
+
+.cityscape-shell.sunset .building-side.left-side {
+ border-right-color: rgba(255, 120, 40, 0.2);
+}
+
+.building-side.right-side {
+ border-left: 1px solid rgba(0, 210, 255, 0.2);
+ padding-left: 15px;
+}
+
+.cityscape-shell.sunset .building-side.right-side {
+ border-left-color: rgba(255, 120, 40, 0.2);
+}
+
+.window {
+ width: 40px;
+ height: 40px;
+ background: linear-gradient(135deg, var(--win-cool) 0%, var(--win-cool) 100%);
+ border: 1px solid rgba(0, 210, 255, 0.5);
+ border-radius: 2px;
+ box-shadow: 0 0 10px rgba(0, 210, 255, 0.4), inset 0 0 8px rgba(0, 210, 255, 0.2);
+ position: relative;
+ animation: windowFlicker 4s ease-in-out infinite;
+}
+
+.cityscape-shell.sunset .window {
+ background: linear-gradient(135deg, var(--win-warm) 0%, var(--win-warm) 100%);
+ border-color: rgba(255, 120, 40, 0.5);
+ box-shadow: 0 0 10px rgba(255, 120, 40, 0.4), inset 0 0 8px rgba(255, 120, 40, 0.2);
+ animation: windowFlickerSunset 4s ease-in-out infinite;
+}
+
+@keyframes windowFlicker {
+ 0%, 100% {
+ box-shadow: 0 0 10px rgba(0, 210, 255, 0.4), inset 0 0 8px rgba(0, 210, 255, 0.2);
+ }
+ 50% {
+ box-shadow: 0 0 15px rgba(0, 210, 255, 0.6), inset 0 0 12px rgba(0, 210, 255, 0.3);
+ }
+}
+
+@keyframes windowFlickerSunset {
+ 0%, 100% {
+ box-shadow: 0 0 10px rgba(255, 120, 40, 0.4), inset 0 0 8px rgba(255, 120, 40, 0.2);
+ }
+ 50% {
+ box-shadow: 0 0 15px rgba(255, 120, 40, 0.6), inset 0 0 12px rgba(255, 120, 40, 0.3);
+ }
+}
+
+.building-center {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 0 20px;
+}
+
+.player-display-window {
+ background: linear-gradient(135deg, rgba(0, 210, 255, 0.1) 0%, rgba(0, 210, 255, 0.05) 100%);
+ border: 2px solid var(--bb-tag);
+ border-radius: 4px;
+ padding: 30px;
+ text-align: center;
+ box-shadow: 0 0 20px rgba(0, 210, 255, 0.3), inset 0 0 15px rgba(0, 210, 255, 0.1);
+ min-width: 250px;
+}
+
+.cityscape-shell.sunset .player-display-window {
+ background: linear-gradient(135deg, rgba(182, 64, 255, 0.1) 0%, rgba(182, 64, 255, 0.05) 100%);
+ box-shadow: 0 0 20px rgba(255, 64, 249, 0.3), inset 0 0 15px rgba(255, 64, 249, 0.1);
+}
+
+.player-avatar {
+ font-size: 4rem;
+ margin-bottom: 15px;
+ display: block;
+ animation: avatarPulse 3s ease-in-out infinite;
+}
+
+@keyframes avatarPulse {
+ 0%, 100% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.05);
+ }
+}
+
+.player-name {
+ font-family: 'Bebas Neue', sans-serif;
+ font-size: 28px;
+ font-weight: bold;
+ color: var(--bb-title);
+ letter-spacing: 1px;
+ margin-bottom: 20px;
+ text-transform: uppercase;
+ background: linear-gradient(90deg, var(--bb-title), var(--bb-tag));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ padding: 8px 12px;
+ border-radius: 4px;
+ position: relative;
+}
+
+.player-name:hover {
+ filter: brightness(1.2);
+ background: linear-gradient(90deg, var(--bb-tag), var(--bb-title));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ transform: scale(1.05);
+}
+
+.player-name.copied {
+ background: linear-gradient(90deg, #4caf50, #66bb6a);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ box-shadow: 0 0 15px rgba(76, 175, 80, 0.5);
+}
+
+.player-id-label {
+ font-family: 'Space Mono', monospace;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ color: var(--bb-tag);
+ margin-bottom: 8px;
+ opacity: 0.8;
+}
+
+.player-id-value {
+ font-family: 'Space Mono', monospace;
+ font-size: 18px;
+ font-weight: 700;
+ letter-spacing: 2px;
+ color: var(--bb-tag);
+ background: rgba(0, 210, 255, 0.15);
+ padding: 12px 16px;
+ border: 1.5px solid var(--bb-tag);
+ border-radius: 2px;
+ word-break: break-all;
+ box-shadow: 0 0 15px rgba(0, 210, 255, 0.4);
+ text-shadow: 0 0 10px rgba(0, 210, 255, 0.5);
+ cursor: pointer;
+ transition: all 0.3s ease;
+ user-select: text;
+}
+
+.cityscape-shell.sunset .player-id-value {
+ background: rgba(182, 64, 255, 0.15);
+ box-shadow: 0 0 15px rgba(255, 64, 249, 0.4);
+ text-shadow: 0 0 10px rgba(255, 64, 249, 0.5);
+}
+
+.player-id-value:hover {
+ background: rgba(0, 210, 255, 0.25);
+ box-shadow: 0 0 20px rgba(0, 210, 255, 0.6);
+ transform: scale(1.02);
+}
+
+.cityscape-shell.sunset .player-id-value:hover {
+ background: rgba(182, 64, 255, 0.25);
+ box-shadow: 0 0 20px rgba(255, 64, 249, 0.6);
+}
+
+.player-id-value.copied {
+ background: rgba(76, 175, 80, 0.2);
+ border-color: #4caf50;
+ color: #4caf50;
+ box-shadow: 0 0 15px rgba(76, 175, 80, 0.5);
+}
+
+.copy-notification {
+ font-family: 'Space Mono', monospace;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 1px;
+ color: #4caf50;
+ margin-top: 12px;
+ animation: copyNotification 2s ease-in-out forwards;
+}
+
+@keyframes copyNotification {
+ 0% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ 80% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ 100% {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+}
+
+.building-base {
+ padding: 20px;
+ background: linear-gradient(180deg, rgba(0, 210, 255, 0.05) 0%, rgba(0, 210, 255, 0.02) 100%);
+ border-top: 1px solid rgba(0, 210, 255, 0.2);
+}
+
+.cityscape-shell.sunset .building-base {
+ background: linear-gradient(180deg, rgba(255, 120, 40, 0.05) 0%, rgba(255, 120, 40, 0.02) 100%);
+ border-top-color: rgba(255, 120, 40, 0.2);
+}
+
+.stat-panel {
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+ gap: 20px;
+}
+
+.stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+}
+
+.stat-label {
+ font-family: 'Space Mono', monospace;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ color: var(--bb-tag);
+ opacity: 0.7;
+}
+
+.stat-value {
+ font-family: 'Space Mono', monospace;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--bb-title);
+ text-align: center;
+}
+
+.stat-divider {
+ width: 1px;
+ height: 30px;
+ background: rgba(0, 210, 255, 0.2);
+}
+
+.cityscape-shell.sunset .stat-divider {
+ background: rgba(255, 120, 40, 0.2);
+}
+
+.building-door {
+ height: 60px;
+ background: linear-gradient(90deg, var(--bldg-mid) 0%, var(--bldg-body) 50%, var(--bldg-mid) 100%);
+ border-top: 1px solid rgba(0, 210, 255, 0.2);
+ border-radius: 0 0 8px 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ overflow: hidden;
+}
+
+.cityscape-shell.sunset .building-door {
+ border-top-color: rgba(255, 120, 40, 0.2);
+}
+
+.building-door::before {
+ content: '';
+ position: absolute;
+ width: 90%;
+ height: 100%;
+ border: 1.5px solid rgba(0, 210, 255, 0.3);
+ border-radius: 4px;
+ background: linear-gradient(90deg, rgba(0, 210, 255, 0.05), rgba(0, 210, 255, 0.02));
+}
+
+.cityscape-shell.sunset .building-door::before {
+ border-color: rgba(255, 120, 40, 0.3);
+ background: linear-gradient(90deg, rgba(255, 120, 40, 0.05), rgba(255, 120, 40, 0.02));
+}
+
+.door-handle {
+ width: 12px;
+ height: 12px;
+ background: radial-gradient(circle at 30% 30%, rgba(0, 210, 255, 0.8), rgba(0, 210, 255, 0.4));
+ border-radius: 50%;
+ box-shadow: 0 0 10px rgba(0, 210, 255, 0.6);
+ position: relative;
+ z-index: 2;
+ margin-left: 80px;
+ animation: handleGlow 2s ease-in-out infinite;
+}
+
+.cityscape-shell.sunset .door-handle {
+ background: radial-gradient(circle at 30% 30%, rgba(255, 120, 40, 0.8), rgba(255, 120, 40, 0.4));
+ box-shadow: 0 0 10px rgba(255, 120, 40, 0.6);
+ animation: handleGlowSunset 2s ease-in-out infinite;
+}
+
+@keyframes handleGlow {
+ 0%, 100% {
+ box-shadow: 0 0 10px rgba(0, 210, 255, 0.6);
+ }
+ 50% {
+ box-shadow: 0 0 20px rgba(0, 210, 255, 0.9);
+ }
+}
+
+@keyframes handleGlowSunset {
+ 0%, 100% {
+ box-shadow: 0 0 10px rgba(255, 120, 40, 0.6);
+ }
+ 50% {
+ box-shadow: 0 0 20px rgba(255, 120, 40, 0.9);
+ }
+}
+
+.back-btn {
+ position: absolute;
+ top: -50px;
+ left: 0;
+ background: transparent;
+ color: var(--bb-title);
+ border: 1px solid var(--dlg-border);
+ border-radius: 2px;
+ padding: 0.5rem 1rem;
+ font-family: 'Space Mono', monospace;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 1px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.back-btn:hover {
+ background: rgba(0, 210, 255, 0.1);
+ border-color: var(--bb-tag);
+ color: var(--bb-tag);
+ box-shadow: 0 0 15px rgba(0, 210, 255, 0.3);
+}
+
+.cityscape-shell.sunset .back-btn:hover {
+ background: rgba(182, 64, 255, 0.1);
+ box-shadow: 0 0 15px rgba(255, 64, 249, 0.3);
+}
+
+@media (max-width: 600px) {
+ .building-structure {
+ border-radius: 4px;
+ }
+
+ .building-main {
+ grid-template-columns: 50px 1fr 50px;
+ padding: 15px;
+ gap: 8px;
+ }
+
+ .window {
+ width: 30px;
+ height: 30px;
+ }
+
+ .player-display-window {
+ min-width: 180px;
+ padding: 20px;
+ }
+
+ .player-avatar {
+ font-size: 3rem;
+ margin-bottom: 10px;
+ }
+
+ .player-name {
+ font-size: 20px;
+ margin-bottom: 15px;
+ }
+
+ .player-id-value {
+ font-size: 14px;
+ padding: 8px 12px;
+ }
+
+ .building-base {
+ padding: 15px;
+ }
+
+ .stat-panel {
+ gap: 10px;
+ }
+
+ .back-btn {
+ top: -45px;
+ font-size: 9px;
+ padding: 0.4rem 0.8rem;
+ }
+}
diff --git a/src/app/pages/profile/profile.component.html b/src/app/pages/profile/profile.component.html
new file mode 100644
index 0000000..3a7e63b
--- /dev/null
+++ b/src/app/pages/profile/profile.component.html
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+ @if (currentUser; as user) {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
👤
+
{{ user.username }}
+
PLAYER ID
+
{{ user.id }}
+ @if (idCopied) {
+
✓ COPIED
+ }
+ @if (usernameCopied) {
+
✓ COPIED
+ }
+
+
+
+
+
+
+
+
+
+
+
+ RATING
+ {{ user.rating }}
+
+
+
+ MEMBER SINCE
+ {{ user.createdAt | date: 'MMM dd, yyyy' }}
+
+
+
+
+
+
+
+
+
+
← BACK
+
+ }
+
+
+
+
+
+
+
diff --git a/src/app/pages/profile/profile.component.ts b/src/app/pages/profile/profile.component.ts
new file mode 100644
index 0000000..e9b6c32
--- /dev/null
+++ b/src/app/pages/profile/profile.component.ts
@@ -0,0 +1,127 @@
+import { Component, OnInit, DestroyRef, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { Router } from '@angular/router';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { AuthService } from '../../services/auth.service';
+import { ThemeService } from '../../services/theme.service';
+import { CurrentUser } from '../../models/auth.models';
+
+interface Star {
+ style: Record;
+}
+
+interface BackgroundBuilding {
+ style: Record;
+}
+
+@Component({
+ selector: 'app-profile',
+ standalone: true,
+ imports: [CommonModule],
+ templateUrl: './profile.component.html',
+ styleUrl: './profile.component.css'
+})
+export class ProfileComponent implements OnInit {
+ private readonly destroyRef = inject(DestroyRef);
+ private readonly authService = inject(AuthService);
+ private readonly themeService = inject(ThemeService);
+ private readonly router = inject(Router);
+
+ currentUser: CurrentUser | null = null;
+ isSunsetMode = false;
+ idCopied = false;
+ usernameCopied = false;
+
+ stars: Star[] = [];
+ bgBuildings: BackgroundBuilding[] = [];
+
+ ngOnInit(): void {
+ this.authService.currentUser$
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((user) => {
+ this.currentUser = user;
+ if (!user) {
+ this.router.navigate(['']);
+ }
+ });
+
+ this.themeService.darkMode$
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((isDarkMode) => {
+ this.isSunsetMode = !isDarkMode;
+ });
+
+ this.generateStars(220);
+ this.generateBackgroundBuildings();
+ }
+
+ goBack(): void {
+ this.router.navigate(['']);
+ }
+
+ copyPlayerId(id: string): void {
+ navigator.clipboard.writeText(id).then(() => {
+ this.idCopied = true;
+ setTimeout(() => {
+ this.idCopied = false;
+ }, 2000);
+ });
+ }
+
+ copyUsername(username: string): void {
+ navigator.clipboard.writeText(username).then(() => {
+ this.usernameCopied = true;
+ setTimeout(() => {
+ this.usernameCopied = false;
+ }, 2000);
+ });
+ }
+
+ private generateStars(count: number): void {
+ this.stars = Array.from({ length: count }, () => {
+ const size = Math.random() * 2 + 0.5;
+ return {
+ style: {
+ width: `${size}px`,
+ height: `${size}px`,
+ left: `${Math.random() * 100}%`,
+ top: `${Math.random() * 62}%`,
+ '--d': `${(Math.random() * 3 + 1.5).toFixed(1)}s`,
+ '--dl': `${-(Math.random() * 6).toFixed(1)}s`
+ }
+ };
+ });
+ }
+
+ private generateBackgroundBuildings(): void {
+ const specs = [
+ { l: '0%', w: '7%', h: '30vh' },
+ { l: '3%', w: '4%', h: '18vh' },
+ { l: '7%', w: '5%', h: '22vh' },
+ { l: '11%', w: '8%', h: '28vh' },
+ { l: '15%', w: '6%', h: '20vh' },
+ { l: '18.5%', w: '4%', h: '18vh' },
+ { l: '22.5%', w: '6%', h: '26vh' },
+ { l: '28%', w: '5%', h: '25vh' },
+ { l: '32%', w: '4%', h: '15vh' },
+ { l: '35.5%', w: '4.5%', h: '20vh' },
+ { l: '42%', w: '5%', h: '28vh' },
+ { l: '47%', w: '5%', h: '22vh' },
+ { l: '50%', w: '7%', h: '30vh' },
+ { l: '55%', w: '6%', h: '27vh' },
+ { l: '60.5%', w: '5%', h: '24vh' },
+ { l: '64.5%', w: '3.5%', h: '17vh' },
+ { l: '70%', w: '6%', h: '23vh' },
+ { l: '75%', w: '4%', h: '19vh' },
+ { l: '80.5%', w: '4%', h: '21vh' },
+ { l: '85.5%', w: '9%', h: '32vh' },
+ { l: '88%', w: '5%', h: '20vh' },
+ { l: '91%', w: '3%', h: '16vh' },
+ { l: '94%', w: '6%', h: '27vh' }
+ ];
+
+ this.bgBuildings = specs.map((spec) => ({
+ style: { left: spec.l, width: spec.w, height: spec.h }
+ }));
+ }
+}
diff --git a/src/app/pages/welcome/welcome.component.css b/src/app/pages/welcome/welcome.component.css
index e11a5ea..7317008 100644
--- a/src/app/pages/welcome/welcome.component.css
+++ b/src/app/pages/welcome/welcome.component.css
@@ -1,656 +1,907 @@
-.welcome-shell {
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: var(--size-xl);
- position: relative;
+@import '../../button-template.css';
+
+:host {
+ display: block;
}
-.theme-toggle-container {
- position: absolute;
- top: 20px;
- right: 20px;
- z-index: 100;
-}
+.cityscape-shell {
+ --sky-1: #04000f;
+ --sky-2: #0e0235;
+ --sky-3: #2d0860;
+ --sky-4: #5e1185;
+ --sky-5: #8b1270;
+ --horizon: #be356e;
+ --bldg-body: #090920;
+ --bldg-mid: #0e0e2a;
+ --bldg-lit: rgba(100, 60, 200, 0.12);
+ --win-off: #0a0a1e;
+ --win-cool: #7de8ff;
+ --win-warm: #ffe88a;
+ --win-glow-c: 0 0 6px #00d5ff, 0 0 16px rgba(0, 200, 255, 0.35);
+ --win-glow-w: 0 0 6px #ffcc30, 0 0 16px rgba(255, 190, 0, 0.35);
+ --bb-bg: rgba(8, 6, 28, 0.92);
+ --bb-border: #00d5ff;
+ --bb-glow: 0 0 18px rgba(0, 210, 255, 0.5), inset 0 0 10px rgba(0, 210, 255, 0.05);
+ --bb-tag: #00d5ff;
+ --bb-title: #d4f4ff;
+ --btn-bg: #00d5ff;
+ --btn-fg: #04000f;
+ --btn-glow: 0 0 14px rgba(0, 210, 255, 0.9);
+ --ground-top: #06060f;
+ --ground-bot: #020208;
+ --moon-vis: 1;
+ --sun-vis: 0;
+ --star-vis: 1;
+ --cloud-col: rgba(255, 255, 255, 0.06);
+ --cloud-col2: rgba(255, 255, 255, 0.04);
+ --bg-bldg-op: 0.65;
+ --haze-col: rgba(6, 6, 15, 0.7);
+ --dlg-bg: rgba(8, 6, 28, 0.95);
+ --dlg-border: #00d5ff;
-.switch {
- display: inline-block;
- position: relative;
-}
-
-.switch__input {
- clip: rect(1px, 1px, 1px, 1px);
- clip-path: inset(50%);
- height: 1px;
- width: 1px;
- margin: -1px;
+ min-height: 100svh;
+ font-family: 'Space Mono', 'Courier New', monospace;
overflow: hidden;
- padding: 0;
- position: absolute;
-}
-
-.switch__label {
+ user-select: none;
position: relative;
- display: inline-block;
- width: 120px;
- height: 60px;
- background-color: #2B2B2B;
- border: 5px solid #5B5B5B;
- border-radius: 9999px;
- cursor: pointer;
- transition: all 0.4s cubic-bezier(.46,.03,.52,.96);
+ background: var(--sky-1);
}
-.switch__indicator {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%) translateX(-72%);
- display: block;
- width: 40px;
- height: 40px;
- background-color: #7B7B7B;
- border-radius: 9999px;
- box-shadow: 10px 0px 0 0 rgba(0, 0, 0, 0.2) inset;
- transition: all 0.4s cubic-bezier(.46,.03,.52,.96);
+.cityscape-shell.sunset {
+ --sky-1: #4d3279;
+ --sky-2: #5A5485;
+ --sky-3: #996C96;
+ --sky-4: #e85040;
+ --sky-5: #f07020;
+ --horizon: #ffaa30;
+ --bldg-body: #13072a;
+ --bldg-mid: #1e0e38;
+ --bldg-lit: rgba(255, 120, 40, 0.14);
+ --win-off: #18082e;
+ --win-cool: #ffcc55;
+ --win-warm: #ff7730;
+ --win-glow-c: 0 0 6px #ea00ff, 0 0 16px rgba(255, 180, 0, 0.4);
+ --win-glow-w: 0 0 6px #ff00e6, 0 0 16px rgba(255, 196, 0, 0.4);
+ --bb-bg: rgba(20, 5, 45, 0.93);
+ --bb-border: #ff40f9;
+ --bb-glow: 0 0 18px rgba(255, 50, 217, 0.55), inset 0 0 10px rgba(255, 120, 30, 0.06);
+ --bb-tag: #b640ff;
+ --bb-title: #ffe0c0;
+ --btn-bg: #f26ae2;
+ --btn-fg: #13072a;
+ --btn-glow: 0 0 14px rgba(238, 50, 255, 0.9);
+ --ground-top: #0a0318;
+ --ground-bot: #04010a;
+ --moon-vis: 0;
+ --sun-vis: 1;
+ --star-vis: 0;
+ --cloud-col: rgba(255, 160, 100, 0.22);
+ --cloud-col2: rgba(255, 100, 60, 0.14);
+ --bg-bldg-op: 0.45;
+ --haze-col: rgba(10, 3, 24, 0.65);
+ --dlg-bg: rgba(20, 5, 45, 0.96);
+ --dlg-border: #ff40cf;
}
-.switch__indicator::before,
-.switch__indicator::after {
- position: absolute;
- content: '';
- display: block;
- background-color: #FFFFFF;
- border-radius: 9999px;
- transition: all 0.4s cubic-bezier(.46,.03,.52,.96);
-}
-
-.switch__indicator::before {
- top: 7px;
- left: 7px;
- width: 9px;
- height: 9px;
- opacity: 0.6;
-}
-
-.switch__indicator::after {
- bottom: 8px;
- right: 6px;
- width: 14px;
- height: 14px;
- opacity: 0.8;
-}
-
-.switch__decoration {
- position: absolute;
- top: 65%;
- left: 50%;
- display: block;
- width: 5px;
- height: 5px;
- background-color: #FFFFFF;
- border-radius: 9999px;
- animation: twinkle-stars 0.8s infinite -0.6s;
- transition: all 0.4s cubic-bezier(.46,.03,.52,.96);
-}
-
-.switch__decoration::before,
-.switch__decoration::after {
- position: absolute;
- display: block;
- content: '';
- width: 5px;
- height: 5px;
- background-color: #FFFFFF;
- border-radius: 9999px;
-}
-
-.switch__decoration::before {
- top: -20px;
- left: 10px;
- opacity: 1;
- animation: twinkle-stars 0.6s infinite;
-}
-
-.switch__decoration::after {
- top: -7px;
- left: 30px;
- animation: twinkle-stars 0.6s infinite -0.2s;
-}
-
-@keyframes twinkle-stars {
- 50% { opacity: 0.2; }
-}
-
-.switch__input:checked + .switch__label {
- background-color: #8FB5F5;
- border-color: #347CF8;
-}
-
-.switch__input:checked + .switch__label .switch__indicator {
- background-color: #ECD21F;
- box-shadow: none;
- transform: translate(-50%, -50%) translateX(72%);
-}
-
-.switch__input:checked + .switch__label .switch__indicator::before,
-.switch__input:checked + .switch__label .switch__indicator::after {
- display: none;
-}
-
-.switch__input:checked + .switch__label .switch__decoration {
- top: 50%;
- transform: translate(0%, -50%);
- animation: cloud 8s linear infinite;
- width: 20px;
- height: 20px;
-}
-
-.switch__input:checked + .switch__label .switch__decoration::before {
- width: 10px;
- height: 10px;
- top: auto;
- bottom: 0;
- left: -8px;
- animation: none;
-}
-
-.switch__input:checked + .switch__label .switch__decoration::after {
- width: 15px;
- height: 15px;
- top: auto;
- bottom: 0;
- left: 16px;
- animation: none;
-}
-
-.switch__input:checked + .switch__label .switch__decoration,
-.switch__input:checked + .switch__label .switch__decoration::before,
-.switch__input:checked + .switch__label .switch__decoration::after {
- border-radius: 9999px 9999px 0 0;
-}
-
-.switch__input:checked + .switch__label .switch__decoration::after {
- border-bottom-right-radius: 9999px;
-}
-
-@keyframes cloud {
- 0% { transform: translate(0%, -50%); }
- 50% { transform: translate(-50%, -50%); }
- 100% { transform: translate(0%, -50%); }
-}
-
-.clouds-container {
- display: flex;
- justify-content: center;
- align-items: flex-start;
- gap: 60px;
+.scene {
+ position: relative;
width: 100%;
- max-width: 900px;
- margin-bottom: var(--size-xl);
- position: relative;
- z-index: 1;
+ height: 100svh;
+ overflow: hidden;
}
-.plane {
- position: relative;
- width: 320px;
- height: 140px;
- display: flex;
- align-items: center;
- justify-content: center;
- filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.12));
-}
-
-.plane-body {
- width: 240%;
- height: 240%;
- object-fit: contain;
+.sky {
position: absolute;
+ inset: 0;
+ background: linear-gradient(180deg, var(--sky-1) 0%, var(--sky-2) 22%, var(--sky-3) 48%, var(--sky-4) 70%, var(--sky-5) 85%, var(--horizon) 100%);
+ transition: background 1.6s ease;
}
-.plane-gif {
- width: 70px;
- height: 70px;
- object-fit: contain;
- z-index: 10;
- filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15));
- position: relative;
- transform: translateX(-25px) translateY(15px);
+.stars-layer {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ opacity: var(--star-vis);
+ transition: opacity 1.6s ease;
+}
+
+.star {
+ position: absolute;
+ border-radius: 50%;
+ background: #ffffff;
+ animation: twinkle var(--d, 3s) ease-in-out infinite var(--dl, 0s);
+}
+
+@keyframes twinkle {
+ 0%,
+ 100% {
+ opacity: 0.15;
+ transform: scale(0.7);
+ }
+
+ 50% {
+ opacity: 1;
+ transform: scale(1.3);
+ }
+}
+
+.moon {
+ position: absolute;
+ top: 9%;
+ right: 14%;
+ width: 52px;
+ height: 52px;
+ border-radius: 50%;
+ background: radial-gradient(circle at 36% 34%, #fffbe8, #f5d060 60%, #c8a020);
+ box-shadow: 0 0 28px rgba(245, 210, 80, 0.55), 0 0 70px rgba(240, 190, 40, 0.25);
+ opacity: var(--moon-vis);
+ transition: opacity 1.6s ease;
+}
+
+.sun {
+ position: absolute;
+ bottom: 12%;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 76px;
+ height: 76px;
+ border-radius: 50%;
+ background: radial-gradient(circle at 50% 50%, #fffce0, #ffd020 40%, #ff9000);
+ box-shadow: 0 0 40px rgba(255, 200, 0, 0.85), 0 0 90px rgba(255, 150, 0, 0.45), 0 0 200px rgba(255, 80, 0, 0.2);
+ opacity: var(--sun-vis);
+ transition: opacity 1.6s ease;
+ z-index: 4;
+}
+
+.cloud-wrap {
+ position: absolute;
+ top: 4%;
+ left: 0;
+ right: 0;
+ height: 18%;
+ pointer-events: none;
}
.cloud {
- position: relative;
+ position: absolute;
+ border-radius: 60px;
+ filter: blur(18px);
+ transition: background 1.6s ease;
+}
+
+.cloud-a {
+ width: 280px;
+ height: 55px;
+ background: var(--cloud-col);
+ top: 10px;
+ left: 4%;
+ animation: drift 50s ease-in-out infinite;
+}
+
+.cloud-b {
+ width: 200px;
+ height: 44px;
+ background: var(--cloud-col2);
+ top: 28px;
+ left: 15%;
+ animation: drift 38s ease-in-out infinite 8s;
+}
+
+.cloud-c {
+ width: 360px;
+ height: 65px;
+ background: var(--cloud-col);
+ top: 5px;
+ left: 36%;
+ animation: drift 62s ease-in-out infinite 3s;
+}
+
+.cloud-d {
width: 220px;
- height: 90px;
+ height: 50px;
+ background: var(--cloud-col2);
+ top: 20px;
+ left: 62%;
+ animation: drift 44s ease-in-out infinite 14s;
+}
+
+.cloud-e {
+ width: 180px;
+ height: 40px;
+ background: var(--cloud-col);
+ top: 35px;
+ left: 80%;
+ animation: drift 56s ease-in-out infinite 6s;
+}
+
+@keyframes drift {
+ 0%,
+ 100% {
+ transform: translateX(0);
+ }
+
+ 50% {
+ transform: translateX(-50px);
+ }
+}
+
+.bg-layer {
+ position: absolute;
+ bottom: 8vh;
+ left: 0;
+ right: 0;
+ z-index: 5;
+ pointer-events: none;
+ opacity: var(--bg-bldg-op);
+ transition: opacity 1.6s ease;
+}
+
+.bg-bldg {
+ position: absolute;
+ bottom: 0;
+ background: var(--bldg-body);
+ transition: background 1.6s ease;
+}
+
+.bg-bldg::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background-image: repeating-linear-gradient(0deg, transparent, transparent 14px, rgba(120, 120, 200, 0.08) 14px, rgba(120, 120, 200, 0.08) 16px),
+ repeating-linear-gradient(90deg, transparent, transparent 12px, rgba(120, 120, 200, 0.08) 12px, rgba(120, 120, 200, 0.08) 14px);
+}
+
+.main-layer {
+ position: absolute;
+ bottom: 8vh;
+ left: 0;
+ right: 0;
+ z-index: 10;
+}
+
+.bwrap {
+ position: absolute;
+ bottom: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+}
+
+.playerone-gif {
+ position: absolute;
+ top: 36px;
+ left: 50%;
+ width: 18px;
+ height: 18px;
+ transform: translateX(-50%);
+ object-fit: contain;
+ z-index: 14;
+ pointer-events: none;
+ image-rendering: auto;
+}
+
+.bpart {
+ position: relative;
+ background: var(--bldg-body);
+ transition: background 1.6s ease;
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+.bpart::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 28%;
+ background: linear-gradient(90deg, transparent, var(--bldg-lit));
+ pointer-events: none;
+ transition: background 1.6s ease;
+}
+
+.wins {
+ display: grid;
+ gap: 3px;
+ padding: 5px;
+ pointer-events: none;
+}
+
+.w {
+ height: 9px;
+ border-radius: 1px;
+ background: var(--win-off);
+ transition: background 0.4s ease, box-shadow 0.4s ease;
+}
+
+.w.lc {
+ background: var(--win-cool);
+ box-shadow: var(--win-glow-c);
+ animation: wflicker var(--wd, 5s) ease-in-out infinite var(--wdl, 0s);
+}
+
+.w.lw {
+ background: var(--win-warm);
+ box-shadow: var(--win-glow-w);
+ animation: wflicker var(--wd, 6s) ease-in-out infinite var(--wdl, 0s);
+}
+
+@keyframes wflicker {
+ 0%,
+ 88%,
+ 92%,
+ 97%,
+ 100% {
+ opacity: 1;
+ }
+
+ 90% {
+ opacity: 0.7;
+ }
+
+ 95% {
+ opacity: 0.85;
+ }
+}
+
+.bb {
+ margin: 0 7px 5px;
+ padding: 10px 12px;
+ background: var(--bb-bg);
+ border: 1.5px solid var(--bb-border);
+ box-shadow: var(--bb-glow);
+ border-radius: 3px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ text-align: center;
+ pointer-events: auto;
+ transition: border-color 1.6s ease, box-shadow 1.6s ease, background 1.6s ease;
+}
+
+.bb-tag {
+ font-family: 'Bebas Neue', sans-serif;
+ font-size: 10px;
+ letter-spacing: 3px;
+ color: var(--bb-tag);
+ opacity: 0.75;
+ transition: color 1.6s ease;
+}
+
+.bb-title {
+ font-family: 'Bebas Neue', sans-serif;
+ font-size: clamp(13px, 1.4vw, 20px);
+ line-height: 1.1;
+ color: var(--bb-title);
+ transition: color 1.6s ease;
+}
+
+.bb-subtitle {
+ font-family: 'Space Mono', monospace;
+ font-size: 9px;
+ color: var(--bb-title);
+ opacity: 0.55;
+ letter-spacing: 0.5px;
+ transition: color 1.6s ease;
+}
+
+.bb-btn {
+ background: var(--btn-bg);
+ color: var(--btn-fg);
+ border: none;
+ border-radius: 2px;
+ padding: 5px 14px;
+ font-family: 'Space Mono', monospace;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 1px;
+ cursor: pointer;
+ box-shadow: var(--btn-glow);
+ transition: transform 0.2s ease, filter 0.2s ease, background 1.6s ease, box-shadow 1.6s ease;
+}
+
+.bb-btn:hover:enabled {
+ transform: scale(1.08);
+ filter: brightness(1.15);
+}
+
+.bb-btn:disabled {
+ opacity: 0.65;
+ cursor: not-allowed;
+}
+
+.neon {
+ font-family: 'Bebas Neue', sans-serif;
+ font-size: 11px;
+ letter-spacing: 2.5px;
+ color: var(--bb-tag);
+ border: 1px solid var(--bb-border);
+ padding: 2px 7px;
+ border-radius: 2px;
+ text-shadow: 0 0 8px currentColor, 0 0 20px currentColor;
+ box-shadow: 0 0 6px currentColor;
+ animation: nflicker 9s ease-in-out infinite;
+ display: inline-block;
+ transition: color 1.6s ease, border-color 1.6s ease;
+}
+
+@keyframes nflicker {
+ 0%,
+ 94%,
+ 96%,
+ 100% {
+ opacity: 1;
+ }
+
+ 95% {
+ opacity: 0.25;
+ }
+
+ 98% {
+ opacity: 0.5;
+ }
+}
+
+.ant {
+ width: 3px;
+ background: var(--bldg-mid);
+ margin: 0 auto;
+ transition: background 1.6s ease;
+ position: relative;
+ flex-shrink: 0;
+}
+
+.ant::after {
+ content: '';
+ position: absolute;
+ top: -5px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #ff2222;
+ box-shadow: 0 0 10px #ff2222, 0 0 20px rgba(255, 0, 0, 0.4);
+ animation: blink 1.6s step-start infinite;
+}
+
+@keyframes blink {
+ 0%,
+ 49% {
+ opacity: 1;
+ }
+
+ 50%,
+ 100% {
+ opacity: 0;
+ }
+}
+
+.ground {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 10vh;
+ background: linear-gradient(180deg, var(--ground-top), var(--ground-bot));
+ z-index: 20;
+ transition: background 1.6s ease;
+}
+
+.haze {
+ position: absolute;
+ bottom: 8vh;
+ left: 0;
+ right: 0;
+ height: 12vh;
+ background: linear-gradient(0deg, var(--haze-col), transparent);
+ z-index: 15;
+ pointer-events: none;
+ transition: background 1.6s ease;
+}
+
+.tgl {
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ z-index: 200;
+ display: inline-grid;
+ place-items: center;
+ width: 56px;
+ height: 56px;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 50%;
+ padding: 0;
+ color: rgba(255, 255, 255, 0.85);
+ cursor: pointer;
+ backdrop-filter: blur(12px);
+ transition: background 0.3s, transform 0.2s;
+}
+
+.tgl:hover {
+ background: rgba(255, 255, 255, 0.14);
+ transform: scale(1.04);
+}
+
+.tgl-icon {
+ font-size: 22px;
+ line-height: 1;
+}
+
+.mode-badge {
+ position: fixed;
+ top: 20px;
+ left: 20px;
+ z-index: 200;
+ font-family: 'Bebas Neue', sans-serif;
+ font-size: 13px;
+ letter-spacing: 4px;
+ color: rgba(255, 255, 255, 0.45);
+ pointer-events: none;
+}
+
+.dialog-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(2, 2, 10, 0.58);
+ display: grid;
+ place-items: center;
+ z-index: 350;
+ padding: 1rem;
+}
+
+.dialog-card {
+ width: min(460px, 100%);
+ background: var(--dlg-bg);
+ border: 1.5px solid var(--dlg-border);
+ box-shadow: var(--bb-glow);
+ border-radius: 4px;
+ padding: 1rem;
+ display: grid;
+ gap: 0.7rem;
+}
+
+.dialog-title {
+ font-family: 'Bebas Neue', sans-serif;
+ font-size: 22px;
+ letter-spacing: 2px;
+ color: var(--bb-title);
+ text-align: center;
+}
+
+.dialog-actions {
+ display: flex;
+ gap: 0.6rem;
+ flex-wrap: wrap;
+}
+
+.dialog-btn {
+ flex: 1;
+ min-width: 120px;
+ background: var(--btn-bg);
+ color: var(--btn-fg);
+ border: none;
+ border-radius: 2px;
+ padding: 0.55rem 0.75rem;
+ font-family: 'Space Mono', monospace;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 1px;
+ cursor: pointer;
+ box-shadow: var(--btn-glow);
+}
+
+.dialog-btn:hover:enabled {
+ filter: brightness(1.12);
+}
+
+.dialog-btn:disabled {
+ opacity: 0.65;
+ cursor: not-allowed;
+}
+
+.dialog-input {
+ width: 100%;
+ background: rgba(4, 4, 20, 0.62);
+ border: 1px solid var(--bb-border);
+ color: var(--bb-title);
+ border-radius: 2px;
+ padding: 0.6rem 0.7rem;
+ font-family: 'Space Mono', monospace;
+ font-size: 13px;
+}
+
+.dialog-input:focus {
+ outline: none;
+ box-shadow: 0 0 0 2px rgba(0, 213, 255, 0.25);
+}
+
+.dialog-textarea {
+ min-height: 120px;
+ resize: vertical;
+}
+
+.import-mode-group {
+ display: flex;
+ gap: 0.8rem;
+}
+
+.import-mode-option {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ color: var(--bb-title);
+ font-size: 13px;
+}
+
+.error-banner {
+ position: fixed;
+ left: 50%;
+ bottom: 1rem;
+ transform: translateX(-50%);
+ z-index: 360;
+ margin: 0;
+ color: #ffd0d0;
+ background: rgba(146, 0, 16, 0.45);
+ border: 1px solid rgba(255, 170, 170, 0.6);
+ border-radius: 9px;
+ padding: 0.5rem 0.7rem;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+/* Speech Bubble Styles */
+.speech-bubble-container {
+ position: fixed;
+ top: 35%;
+ left: 55%;
+ transform: translate(-50%, -50%);
+ z-index: 500;
+ cursor: pointer;
+ animation: slideInBubble 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
+}
+
+@keyframes slideInBubble {
+ 0% {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.5);
+ }
+ 100% {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+ }
+}
+
+.speech-bubble {
+ background: linear-gradient(135deg, #B9DAD1 0%, #B9C2DA 100%);
+ border: 2px solid #8b1270;
+ border-radius: 20px;
+ padding: 16px 24px;
+ font-family: 'Comic Sans MS', 'Comic Sans', cursive;
+ font-size: 18px;
+ font-weight: bold;
+ color: #5A2C28;
+ white-space: nowrap;
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2),
+ inset 0 1px 3px rgba(255, 255, 255, 0.3);
+ position: relative;
+ transition: all 0.3s ease;
+}
+
+.speech-bubble:hover {
+ transform: scale(1.05);
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3),
+ inset 0 1px 3px rgba(255, 255, 255, 0.5);
+}
+
+.bubble-text {
+ margin: 0;
+}
+
+.bubble-tail {
+ position: absolute;
+ bottom: -12px;
+ left: 20px;
+ width: 0;
+ height: 0;
+ border-left: 10px solid transparent;
+ border-right: 0px solid transparent;
+ border-top: 12px solid #B9DAD1;
+}
+
+/* Zoom Overlay and Window */
+.zoom-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.7);
+ z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
- filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.12));
+ animation: fadeIn 0.3s ease;
+ cursor: pointer;
}
-.cloud::before {
- content: '';
- position: absolute;
- width: 90px;
- height: 90px;
- background: #ffffff;
- border-radius: 50%;
- top: 0;
- left: 0;
- box-shadow: 55px 0 0 9px #ffffff, 110px 0 0 5px #ffffff, 27px -18px 0 13px #ffffff, 82px -13px 0 11px #ffffff;
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
}
-.cloud::after {
- content: '';
- position: absolute;
- width: 220px;
- height: 45px;
- background: #ffffff;
- border-radius: 50px;
- bottom: 0;
- left: 0;
+.zoom-window-wrapper {
+ cursor: auto;
+ animation: zoomInWindow 1.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
-.cloud-gif {
- width: 150px;
- height: 100px;
- object-fit: contain;
- z-index: 10;
- filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15));
+@keyframes zoomInWindow {
+ 0% {
+ transform: scale(0.1);
+ opacity: 0;
+ }
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+.zoom-window-frame {
+ background: #13072a;
+ border: 8px solid #f26ae2;
+ border-radius: 16px;
+ padding: 40px 20px 20px 20px;
+ box-shadow: 0 0 40px rgba(242, 106, 226, 0.6),
+ inset 0 0 20px rgba(242, 106, 226, 0.2);
+ max-width: 90vw;
+ max-height: 90vh;
position: relative;
}
-.gif-with-halo {
+.zoom-player-2 {
position: relative;
- display: inline-block;
+ display: flex;
+ align-items: center;
+ justify-content: center;
}
-.gif-with-halo::before {
- content: '';
+.player-2-gif {
+ max-width: 100%;
+ max-height: 70vh;
+ width: auto;
+ height: auto;
+ display: block;
+ border-radius: 12px;
+ cursor: pointer;
+ transition: transform 0.2s ease;
+}
+
+.player-2-gif:hover {
+ transform: scale(1.02);
+}
+
+.second-speech-bubble {
position: absolute;
- top: -10px;
+ top: -60px;
left: 50%;
transform: translateX(-50%);
- width: 75px;
- height: 15px;
- border: 2px solid rgba(255, 215, 0, 0.7);
- border-radius: 50%;
- box-shadow: 0 0 8px rgba(255, 215, 0, 0.5);
- z-index: 5;
+ background: linear-gradient(135deg, #C19EF5 0%, #E1EAA9 100%);
+ border: 2px solid #BA6D4B;
+ border-radius: 20px;
+ padding: 12px 18px;
+ font-family: 'Comic Sans MS', 'Comic Sans', cursive;
+ font-size: 16px;
+ font-weight: bold;
+ color: #5A2C28;
+ white-space: nowrap;
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2),
+ inset 0 1px 3px rgba(255, 255, 255, 0.3);
+ animation: popInBubble 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
+ z-index: 10;
}
-.welcome-card {
- width: min(900px, 100%);
- border-radius: var(--border-radius-lg);
- border: var(--border-width) solid var(--color-border);
- background: var(--color-bg-main);
- padding: var(--size-xl-padding);
- box-shadow: var(--shadow-md);
-}
-
-h1 {
- margin: 0 0 var(--size-xs);
- color: var(--color-text-primary);
- font-size: var(--heading-h1);
-}
-
-p {
- margin: 0 0 var(--size-lg);
-}
-
-.mode-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
- gap: var(--size-xl-gap);
-}
-
-.mode {
- border: var(--border-width) solid var(--color-border);
- border-radius: var(--border-radius-md);
- padding: var(--size-lg-padding);
- text-align: left;
- display: grid;
- gap: var(--size-xs);
-}
-
-.mode span {
- font-size: 1.15rem;
- color: var(--color-text-primary);
-}
-
-.mode small {
- color: var(--color-text-primary);
- opacity: 0.9;
-}
-
-.mode-active {
- background: var(--color-secondary-mint);
- cursor: pointer;
-}
-
-.mode-active:hover:enabled {
- background: var(--color-secondary-blue);
-}
-
-.mode-disabled {
- background: var(--color-bg-card);
- opacity: 0.75;
-}
-
-.error {
- color: var(--color-text-primary);
- font-weight: 700;
- margin-top: var(--size-xl);
-}
-
-.plane-left {
- animation: float 3s ease-in-out infinite;
-}
-
-.cloud-left {
- animation: float 3s ease-in-out infinite;
- animation-delay: 0.25s;
-}
-
-.cloud-right {
- animation: float 3s ease-in-out infinite;
- animation-delay: 0.5s;
-}
-
-@keyframes float {
- 0%, 100% {
- transform: translateY(0px);
+@keyframes popInBubble {
+ 0% {
+ opacity: 0;
+ transform: translateX(-50%) scale(0.3);
}
- 50% {
- transform: translateY(-15px);
+ 100% {
+ opacity: 1;
+ transform: translateX(-50%) scale(1);
}
}
-@media (max-width: 768px) {
- .theme-toggle-container {
- top: 10px;
- right: 10px;
- }
-
- .switch__label {
- width: 100px;
- height: 50px;
- }
-
- .switch__indicator {
- width: 33px;
- height: 33px;
- }
-
- .welcome-shell {
- padding: var(--size-lg);
- }
-
- .clouds-container {
- gap: 30px;
- margin-bottom: var(--size-lg);
- }
-
- .plane {
- width: 240px;
- height: 110px;
- }
-
- .plane-gif {
- width: 55px;
- height: 55px;
- transform: translateX(-20px) translateY(12px);
- }
-
- .cloud {
- width: 170px;
- height: 75px;
- }
-
- .cloud::before {
- width: 65px;
- height: 65px;
- box-shadow: 40px 0 0 7px #ffffff, 80px 0 0 3px #ffffff, 20px -13px 0 10px #ffffff, 60px -10px 0 8px #ffffff;
- }
-
- .cloud::after {
- width: 170px;
- height: 38px;
- }
-
- .cloud-gif {
- width: 140px;
- height: 140px;
- }
-
- .gif-with-halo::before {
- top: -8px;
- width: 80px;
- height: 12px;
- border: 1.5px solid rgba(255, 215, 0, 0.7);
- }
-
- .welcome-card {
- padding: var(--size-xl);
- }
-
- h1 {
- font-size: var(--heading-h1-mobile);
- }
-
- p {
- font-size: 0.875rem;
- margin: 0 0 var(--size-lg);
- }
-
- .mode-grid {
- grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
- gap: var(--size-md-gap);
- }
-
- .mode {
- padding: var(--size-md-padding);
- gap: var(--size-xs-gap);
- }
-
- .mode span {
- font-size: 1rem;
- }
-
- .mode small {
- font-size: 0.7rem;
- }
-
- .error {
- font-size: 0.75rem;
- }
+.second-speech-bubble .bubble-tail {
+ top: 100%;
+ bottom: auto;
+ left: 50%;
+ transform: translateX(-50%);
+ border-top: 12px solid #C19EF5;
}
-@media (max-width: 480px) {
- .welcome-shell {
- padding: var(--size-sm);
- }
-
- .welcome-card {
- padding: var(--size-lg-padding);
- border-radius: var(--border-radius-md);
- }
-
- h1 {
- font-size: var(--heading-h1-small);
- margin: 0 0 var(--size-xs);
- }
-
- p {
- font-size: 0.8rem;
- margin: 0 0 var(--size-md);
- }
-
- .mode-grid {
- grid-template-columns: 1fr;
- }
-
- .mode {
- padding: var(--size-md-padding);
- }
-
- .mode span {
- font-size: 0.95rem;
- }
+/* Happy Meow Bubble */
+.happy-speech-bubble {
+ position: absolute;
+ top: -60px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: linear-gradient(135deg, #F3C8A0 0%, #BA6D4B 100%);
+ border: 2px solid #5A2C28;
+ border-radius: 20px;
+ padding: 12px 18px;
+ font-family: 'Comic Sans MS', 'Comic Sans', cursive;
+ font-size: 16px;
+ font-weight: bold;
+ color: #fff;
+ white-space: nowrap;
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3),
+ inset 0 1px 3px rgba(255, 255, 255, 0.4),
+ 0 0 20px rgba(243, 200, 160, 0.5);
+ animation: popInBubble 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
+ z-index: 10;
}
-.difficulty-selector {
- grid-column: 1 / -1;
- background: var(--color-bg-card);
- border: var(--border-width) solid var(--color-border);
- border-radius: var(--border-radius-md);
- padding: var(--size-lg-padding);
- margin: var(--size-md) 0;
+.happy-speech-bubble .bubble-tail {
+ top: 100%;
+ bottom: auto;
+ left: 50%;
+ transform: translateX(-50%);
+ border-top: 12px solid #F3C8A0;
}
-.difficulty-selector p {
- margin: 0 0 var(--size-md);
- font-weight: 600;
-}
-
-.difficulty-buttons {
+/* Meat Emoji */
+.meat-emoji {
+ position: fixed;
+ font-size: 48px;
+ cursor: grab;
+ user-select: none;
+ z-index: 1001;
display: flex;
- gap: var(--size-md-gap);
- flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ width: 60px;
+ height: 60px;
+ animation: meatAppear 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
+ filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
+ transition: transform 0.1s ease;
}
-.difficulty-btn {
- flex: 1;
- min-width: 80px;
- padding: var(--size-md-padding);
- border: var(--border-width) solid var(--color-border);
- border-radius: var(--border-radius-md);
- cursor: pointer;
- font-weight: 600;
- transition: all 0.3s ease;
+.meat-emoji:active {
+ cursor: grabbing;
+ transform: scale(1.1);
+ filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5));
}
-.difficulty-btn.easy {
- background: var(--color-success-light, #d4edda);
- color: var(--color-text-primary);
+@keyframes meatAppear {
+ 0% {
+ opacity: 0;
+ transform: scale(0);
+ }
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
}
-.difficulty-btn.easy:hover:enabled {
- background: var(--color-success, #28a745);
- color: white;
+@media (max-width: 900px) {
+ .bwrap {
+ transform: scale(0.9);
+ transform-origin: bottom center;
+ }
}
-.difficulty-btn.medium {
- background: var(--color-warning-light, #fff3cd);
- color: var(--color-text-primary);
-}
+@media (max-width: 700px) {
+ .scene {
+ transform: scale(0.8);
+ transform-origin: bottom center;
+ width: 125%;
+ margin-left: -12.5%;
+ }
-.difficulty-btn.medium:hover:enabled {
- background: var(--color-warning, #ffc107);
- color: var(--color-text-primary);
-}
-
-.difficulty-btn.hard {
- background: var(--color-danger-light, #f8d7da);
- color: var(--color-text-primary);
-}
-
-.difficulty-btn.hard:hover:enabled {
- background: var(--color-danger, #dc3545);
- color: white;
-}
-
-.join-game-form {
- grid-column: 1 / -1;
- background: var(--color-bg-card);
- border: var(--border-width) solid var(--color-border);
- border-radius: var(--border-radius-md);
- padding: var(--size-lg-padding);
- margin: var(--size-md) 0;
-}
-
-.join-game-form p {
- margin: 0 0 var(--size-md);
- font-weight: 600;
-}
-
-.join-game-input-group {
- display: flex;
- gap: var(--size-md-gap);
- flex-wrap: wrap;
-}
-
-.join-game-input {
- flex: 1;
- min-width: 150px;
- padding: var(--size-md-padding);
- border: var(--border-width) solid var(--color-border);
- border-radius: var(--border-radius-md);
- background: white;
- color: var(--color-text-primary);
- font-family: inherit;
- font-size: 1rem;
- transition: border-color 0.3s ease;
-}
-
-.join-game-input:focus {
- outline: none;
- border-color: var(--color-secondary-mint);
- box-shadow: 0 0 0 3px rgba(185, 218, 209, 0.2);
-}
-
-.join-game-input:disabled {
- background: var(--color-bg-card);
- opacity: 0.6;
-}
-
-.join-game-btn {
- padding: var(--size-md-padding) var(--size-lg);
- border: var(--border-width) solid var(--color-border);
- border-radius: var(--border-radius-md);
- cursor: pointer;
- font-weight: 600;
- font-size: 0.95rem;
- transition: all 0.3s ease;
-}
-
-.join-game-btn.join {
- background: var(--color-secondary-mint);
- color: var(--color-text-primary);
-}
-
-.join-game-btn.join:hover:enabled {
- background: var(--color-secondary-blue);
-}
-
-.join-game-btn.join:disabled {
- opacity: 0.6;
- cursor: not-allowed;
-}
-
-.join-game-btn.cancel {
- background: var(--color-bg-card);
- color: var(--color-text-primary);
-}
-
-.join-game-btn.cancel:hover:enabled {
- background: var(--color-border);
-}
-
-.join-game-btn.cancel:disabled {
- opacity: 0.6;
- cursor: not-allowed;
-}
-
-.difficulty-btn.hard:hover:enabled {
- background: var(--color-danger, #dc3545);
- color: white;
-}
-
-.difficulty-btn:disabled {
- opacity: 0.6;
- cursor: not-allowed;
+ .dialog-card {
+ width: min(380px, 100%);
+ }
}
diff --git a/src/app/pages/welcome/welcome.component.html b/src/app/pages/welcome/welcome.component.html
index d5b67d8..ec83f91 100644
--- a/src/app/pages/welcome/welcome.component.html
+++ b/src/app/pages/welcome/welcome.component.html
@@ -1,99 +1,262 @@
-
-
-
-
-

-
-
-

-

-
-
-
-

+
+
+
-
- Welcome to NowChess
- Pick a mode to begin.
-
-
- Bot
- {{ creating ? 'Creating game...' : 'Choose difficulty' }}
-
+
- @if (showDifficultySelector) {
-
-
Select difficulty:
-
\ No newline at end of file
diff --git a/src/app/pages/welcome/welcome.component.ts b/src/app/pages/welcome/welcome.component.ts
index 3f3c149..749de98 100644
--- a/src/app/pages/welcome/welcome.component.ts
+++ b/src/app/pages/welcome/welcome.component.ts
@@ -1,41 +1,342 @@
import { CommonModule } from '@angular/common';
-import { Component } from '@angular/core';
+import { Component, DestroyRef, OnDestroy, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { finalize } from 'rxjs';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { getErrorMessage } from '../../core/http/error-message.util';
+import { CurrentUser } from '../../models/auth.models';
+import { AuthDialogService } from '../../services/auth-dialog.service';
+import { AuthService } from '../../services/auth.service';
import { GameApiService } from '../../services/game-api.service';
+import { ThemeService } from '../../services/theme.service';
+
+type Difficulty = 'easy' | 'medium' | 'hard';
+type ImportMode = 'fen' | 'pgn';
+
+interface Star {
+ style: Record
;
+}
+
+interface BackgroundBuilding {
+ style: Record;
+}
+
+interface WindowCell {
+ state: 'off' | 'on';
+ color?: string;
+ glowColor?: string;
+ style: Record;
+}
@Component({
selector: 'app-welcome',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './welcome.component.html',
- styleUrl: './welcome.component.css'
+ styleUrls: ['./welcome.component.css']
})
-export class WelcomeComponent {
+export class WelcomeComponent implements OnInit, OnDestroy {
+ private readonly destroyRef = inject(DestroyRef);
+ private readonly authService = inject(AuthService);
+ private readonly authDialogService = inject(AuthDialogService);
+ private readonly themeService = inject(ThemeService);
+
creating = false;
- errorMessage = '';
- showDifficultySelector = false;
- showJoinGameForm = false;
- gameIdInput = '';
joiningGame = false;
+ importing = false;
+ errorMessage = '';
+
+ showDifficultyDialog = false;
+ showOptionsDialog = false;
+ showJoinDialog = false;
+ showImportDialog = false;
+
+ gameIdInput = '';
+ importMode: ImportMode = 'fen';
+ importText = '';
+
+ isSunsetMode = false;
+ modeBadge = 'NIGHT MODE';
+ currentUser: CurrentUser | null = null;
+ private authDialogState: 'login' | 'register' | null = null;
+ private pendingAction: (() => void) | null = null;
+
+ // Speech bubble and zoom features
+ showSpeechBubble = false;
+ isZoomedIn = false;
+ showSecondSpeechBubble = false;
+ showHappyBubble = false;
+ showMeatEmoji = false;
+ bubbleMessage = 'meow';
+
+ // Meat emoji drag state
+ meatX = 0;
+ meatY = 0;
+ isDraggingMeat = false;
+ meatDragOffsetX = 0;
+ meatDragOffsetY = 0;
+
+ stars: Star[] = [];
+ bgBuildings: BackgroundBuilding[] = [];
+ windows: Record = {};
+
+ private flickerIntervalId: ReturnType | undefined;
+ private speechBubbleTimeoutId: ReturnType | undefined;
+ private zoomTimeoutId: ReturnType | undefined;
+
+ private coolColors = ['#7de8ff', '#00d5ff', '#5bc0de', '#31b0d5', '#4fc3f7', '#29b6f6'];
+ private coolGlowColors = ['#00d5ff', '#00d5ff', '#31b0d5', '#31b0d5', '#03a9f4', '#0288d1'];
+
+ private warmColors = ['#ffe88a', '#ffcc30', '#f0ad4e', '#ec971f', '#ffb74d', '#ffa726'];
+ private warmGlowColors = ['#ffcc30', '#ffcc30', '#ec971f', '#ec971f', '#ff9800', '#fb8c00'];
constructor(
private readonly router: Router,
private readonly gameApi: GameApiService
) {
- this.initTheme();
}
- private initTheme(): void {
- const savedTheme = localStorage.getItem('theme');
- if (savedTheme === 'dark') {
- document.documentElement.setAttribute('data-theme', 'dark');
+ ngOnInit(): void {
+ this.themeService.darkMode$
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((isDarkMode) => {
+ this.isSunsetMode = !isDarkMode;
+ this.modeBadge = this.isSunsetMode ? 'SUNSET MODE' : 'NIGHT MODE';
+ });
+
+ this.authService.currentUser$
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((user) => {
+ this.currentUser = user;
+ this.maybeRunPendingAction();
+ });
+
+ this.authDialogService.dialogState$
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((state) => {
+ this.authDialogState = state;
+ this.maybeRunPendingAction();
+ });
+
+ this.generateStars(220);
+ this.generateBackgroundBuildings();
+ this.generateWindowsForAllBuildings();
+ this.startWindowFlicker();
+
+ // Show speech bubble after 5 seconds
+ this.speechBubbleTimeoutId = setTimeout(() => {
+ this.showSpeechBubble = true;
+ }, 5000);
+ }
+
+ ngOnDestroy(): void {
+ this.stopWindowFlicker();
+ if (this.speechBubbleTimeoutId) {
+ clearTimeout(this.speechBubbleTimeoutId);
+ }
+ if (this.zoomTimeoutId) {
+ clearTimeout(this.zoomTimeoutId);
}
}
+ openDifficultyDialog(): void {
+ if (!this.requireAuth(() => this.showDifficultyDialog = true)) {
+ return;
+ }
+
+ this.closeAllDialogs();
+ this.showDifficultyDialog = true;
+ }
+
+ closeDifficultyDialog(): void {
+ this.showDifficultyDialog = false;
+ this.errorMessage = '';
+ }
+
+ openOptionsDialog(): void {
+ this.closeAllDialogs();
+ this.showOptionsDialog = true;
+ }
+
+ closeOptionsDialog(): void {
+ this.showOptionsDialog = false;
+ this.errorMessage = '';
+ }
+
+ openJoinDialog(): void {
+ if (!this.requireAuth(() => this.showJoinDialog = true)) {
+ return;
+ }
+
+ this.closeAllDialogs();
+ this.showJoinDialog = true;
+ }
+
+ closeJoinDialog(): void {
+ if (this.joiningGame) {
+ return;
+ }
+ this.showJoinDialog = false;
+ this.gameIdInput = '';
+ this.errorMessage = '';
+ }
+
+ openImportDialog(): void {
+ if (!this.requireAuth(() => this.showImportDialog = true)) {
+ return;
+ }
+
+ this.closeAllDialogs();
+ this.showImportDialog = true;
+ }
+
+ closeImportDialog(): void {
+ if (this.importing) {
+ return;
+ }
+ this.showImportDialog = false;
+ this.importText = '';
+ this.importMode = 'fen';
+ this.errorMessage = '';
+ }
+
+ setImportMode(mode: ImportMode): void {
+ this.importMode = mode;
+ this.errorMessage = '';
+ }
+
startOneVsOne(): void {
+ if (!this.requireAuth(() => this.performStartOneVsOne())) {
+ return;
+ }
+
+ this.performStartOneVsOne();
+ }
+
+ startVsBot(difficulty: Difficulty): void {
+ if (!this.requireAuth(() => this.performStartVsBot(difficulty))) {
+ return;
+ }
+
+ this.performStartVsBot(difficulty);
+ }
+
+ submitJoinGame(): void {
+ if (!this.requireAuth(() => this.performSubmitJoinGame())) {
+ return;
+ }
+
+ this.performSubmitJoinGame();
+ }
+
+ submitImportGame(): void {
+ if (!this.requireAuth(() => this.performSubmitImportGame())) {
+ return;
+ }
+
+ this.performSubmitImportGame();
+ }
+
+ onSpeechBubbleClick(): void {
+ this.showSpeechBubble = false;
+ this.isZoomedIn = true;
+ this.bubbleMessage = 'meow';
+ this.showMeatEmoji = true;
+ this.showHappyBubble = false;
+ this.showSecondSpeechBubble = true;
+
+ // Reset meat position
+ this.meatX = window.innerWidth / 2 - 100;
+ this.meatY = window.innerHeight / 2 + 150;
+ }
+
+ onZoomedViewClick(): void {
+ this.isZoomedIn = false;
+ this.showSecondSpeechBubble = false;
+ this.showHappyBubble = false;
+ this.showMeatEmoji = false;
+ this.bubbleMessage = 'meow';
+
+ if (this.zoomTimeoutId) {
+ clearTimeout(this.zoomTimeoutId);
+ }
+ }
+
+ onMeatMouseDown(event: MouseEvent): void {
+ this.isDraggingMeat = true;
+ const rect = (event.target as HTMLElement).getBoundingClientRect();
+ this.meatDragOffsetX = event.clientX - rect.left;
+ this.meatDragOffsetY = event.clientY - rect.top;
+ }
+
+ onMouseMove(event: MouseEvent): void {
+ if (!this.isDraggingMeat) {
+ return;
+ }
+
+ this.meatX = event.clientX - this.meatDragOffsetX;
+ this.meatY = event.clientY - this.meatDragOffsetY;
+
+ const gifElement = document.querySelector('.player-2-gif') as HTMLElement;
+ if (!gifElement) {
+ return;
+ }
+
+ const gifRect = gifElement.getBoundingClientRect();
+ const gifCenterX = gifRect.left + gifRect.width / 2;
+ const gifCenterY = gifRect.top + gifRect.height / 2;
+
+ const meatElement = document.querySelector('.meat-emoji') as HTMLElement;
+ if (!meatElement) {
+ return;
+ }
+
+ const meatRect = meatElement.getBoundingClientRect();
+ const meatCenterX = meatRect.left + meatRect.width / 2;
+ const meatCenterY = meatRect.top + meatRect.height / 2;
+
+ const distance = Math.sqrt(
+ Math.pow(meatCenterX - gifCenterX, 2) + Math.pow(meatCenterY - gifCenterY, 2)
+ );
+
+ if (distance < 50) {
+ this.onMeatFed();
+ }
+ }
+
+ onMouseUp(): void {
+ this.isDraggingMeat = false;
+ }
+
+ onMeatFed(): void {
+ this.showMeatEmoji = false;
+ this.showSecondSpeechBubble = false;
+ this.showHappyBubble = true;
+ this.isDraggingMeat = false;
+ }
+
+ private requireAuth(action: () => void): boolean {
+ if (this.authService.isLoggedIn()) {
+ return true;
+ }
+
+ this.pendingAction = action;
+ this.authDialogService.openLogin();
+ return false;
+ }
+
+ private maybeRunPendingAction(): void {
+ if (!this.currentUser || this.authDialogState !== null || !this.pendingAction) {
+ return;
+ }
+
+ const action = this.pendingAction;
+ this.pendingAction = null;
+ action();
+ }
+
+ private performStartOneVsOne(): void {
if (this.creating) {
return;
}
@@ -48,7 +349,9 @@ export class WelcomeComponent {
.pipe(finalize(() => (this.creating = false)))
.subscribe({
next: (game) => {
- void this.router.navigate(['/game', game.gameId]);
+ void this.router.navigate(['/game', game.gameId], {
+ state: { theme: this.isSunsetMode ? 'light' : 'dark' }
+ });
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Unable to create a game.');
@@ -56,21 +359,23 @@ export class WelcomeComponent {
});
}
- startVsBot(difficulty: 'easy' | 'medium' | 'hard'): void {
+ private performStartVsBot(difficulty: Difficulty): void {
if (this.creating) {
return;
}
this.errorMessage = '';
this.creating = true;
- this.showDifficultySelector = false;
+ this.showDifficultyDialog = false;
this.gameApi
.createGameVsBot(difficulty)
.pipe(finalize(() => (this.creating = false)))
.subscribe({
next: (game) => {
- void this.router.navigate(['/game', game.gameId]);
+ void this.router.navigate(['/game', game.gameId], {
+ state: { theme: this.isSunsetMode ? 'light' : 'dark' }
+ });
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Unable to create a game against bot.');
@@ -78,21 +383,9 @@ export class WelcomeComponent {
});
}
- toggleDifficultySelector(): void {
- this.showDifficultySelector = !this.showDifficultySelector;
- this.showJoinGameForm = false;
- this.errorMessage = '';
- }
-
- toggleJoinGameForm(): void {
- this.showJoinGameForm = !this.showJoinGameForm;
- this.showDifficultySelector = false;
- this.errorMessage = '';
- this.gameIdInput = '';
- }
-
- joinGame(): void {
- if (this.joiningGame || !this.gameIdInput.trim()) {
+ private performSubmitJoinGame(): void {
+ const gameId = this.gameIdInput.trim();
+ if (this.joiningGame || !gameId) {
return;
}
@@ -100,11 +393,14 @@ export class WelcomeComponent {
this.joiningGame = true;
this.gameApi
- .getGame(this.gameIdInput.trim())
+ .getGame(gameId)
.pipe(finalize(() => (this.joiningGame = false)))
.subscribe({
next: (game) => {
- void this.router.navigate(['/game', game.gameId]);
+ this.closeJoinDialog();
+ void this.router.navigate(['/game', game.gameId], {
+ state: { theme: this.isSunsetMode ? 'light' : 'dark' }
+ });
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Unable to find or join the game.');
@@ -112,26 +408,193 @@ export class WelcomeComponent {
});
}
- clearJoinGameForm(): void {
- this.showJoinGameForm = false;
- this.gameIdInput = '';
+ private performSubmitImportGame(): void {
+ const trimmedImport = this.importText.trim();
+ if (this.importing || !trimmedImport) {
+ return;
+ }
+
+ this.errorMessage = '';
+ this.importing = true;
+
+ const importRequest =
+ this.importMode === 'fen' ? this.gameApi.importFen(trimmedImport) : this.gameApi.importPgn(trimmedImport);
+
+ importRequest.pipe(finalize(() => (this.importing = false))).subscribe({
+ next: (game) => {
+ this.closeImportDialog();
+ void this.router.navigate(['/game', game.gameId], {
+ state: { theme: this.isSunsetMode ? 'light' : 'dark' }
+ });
+ },
+ error: (error) => {
+ const defaultMessage = this.importMode === 'fen' ? 'Unable to import FEN.' : 'Unable to import PGN.';
+ this.errorMessage = getErrorMessage(error, defaultMessage);
+ }
+ });
+ }
+
+ private closeAllDialogs(): void {
+ this.showDifficultyDialog = false;
+ this.showOptionsDialog = false;
+ this.showJoinDialog = false;
+ this.showImportDialog = false;
this.errorMessage = '';
}
- toggleDarkMode(): void {
- const htmlElement = document.documentElement;
- const isDarkMode = htmlElement.getAttribute('data-theme') === 'dark';
-
- if (isDarkMode) {
- htmlElement.removeAttribute('data-theme');
- localStorage.removeItem('theme');
- } else {
- htmlElement.setAttribute('data-theme', 'dark');
- localStorage.setItem('theme', 'dark');
- }
+ private generateStars(count: number): void {
+ this.stars = Array.from({ length: count }, () => {
+ const size = Math.random() * 2 + 0.5;
+ return {
+ style: {
+ width: `${size}px`,
+ height: `${size}px`,
+ left: `${Math.random() * 100}%`,
+ top: `${Math.random() * 62}%`,
+ '--d': `${(Math.random() * 3 + 1.5).toFixed(1)}s`,
+ '--dl': `${-(Math.random() * 6).toFixed(1)}s`
+ }
+ };
+ });
}
- isDarkMode(): boolean {
- return document.documentElement.getAttribute('data-theme') === 'dark';
+ private generateBackgroundBuildings(): void {
+ const specs = [
+ { l: '0%', w: '7%', h: '30vh' },
+ { l: '3%', w: '4%', h: '18vh' }, // New building
+ { l: '7%', w: '5%', h: '22vh' },
+ { l: '11%', w: '8%', h: '28vh' },
+ { l: '15%', w: '6%', h: '20vh' },
+ { l: '18.5%', w: '4%', h: '18vh' },
+ { l: '22.5%', w: '6%', h: '26vh' },
+ { l: '28%', w: '5%', h: '25vh' },
+ { l: '32%', w: '4%', h: '15vh' },
+ { l: '35.5%', w: '4.5%', h: '20vh' },
+ { l: '42%', w: '5%', h: '28vh' },
+ { l: '47%', w: '5%', h: '22vh' }, // New building
+ { l: '50%', w: '7%', h: '30vh' },
+ { l: '55%', w: '6%', h: '27vh' },
+ { l: '60.5%', w: '5%', h: '24vh' },
+ { l: '64.5%', w: '3.5%', h: '17vh' },
+ { l: '70%', w: '6%', h: '23vh' },
+ { l: '75%', w: '4%', h: '19vh' },
+ { l: '80.5%', w: '4%', h: '21vh' },
+ { l: '85.5%', w: '9%', h: '32vh' },
+ { l: '88%', w: '5%', h: '20vh' },
+ { l: '91%', w: '3%', h: '16vh' }, // New building
+ { l: '94%', w: '6%', h: '27vh' }
+ ];
+
+ this.bgBuildings = specs.map((spec) => ({
+ style: { left: spec.l, width: spec.w, height: spec.h }
+ }));
+ }
+
+ private generateWindowsForAllBuildings(): void {
+ this.windows = {
+ wA1: this.generateWindows(3, 4, 0.6),
+ wA2: this.generateWindows(4, 5, 0.55),
+ wA3: this.generateWindows(5, 18, 0.5),
+ wB1: this.generateWindows(4, 3, 0.6),
+ wB2: this.generateWindows(5, 20, 0.55),
+ wC1: this.generateWindows(5, 3, 0.7),
+ wC2: this.generateWindows(6, 5, 0.65),
+ wC3: this.generateWindows(7, 24, 0.6),
+ wD1: this.generateWindows(6, 3, 0.6),
+ wD2: this.generateWindows(6, 20, 0.5),
+ wE1: this.generateWindows(3, 16, 0.45)
+ };
+ }
+
+ private generateWindows(cols: number, rows: number, litRate: number): WindowCell[] {
+ const total = cols * rows;
+ return Array.from({ length: total }, () => this.createWindowCell(litRate));
+ }
+
+ private createWindowCell(litRate: number): WindowCell {
+ const random = Math.random();
+ let state: WindowCell['state'] = 'off';
+ let color: string | undefined;
+ let glowColor: string | undefined;
+
+ if (random < litRate * 0.58) { // Cool color
+ state = 'on';
+ const coolIndex = Math.floor(Math.random() * this.coolColors.length);
+ color = this.coolColors[coolIndex];
+ glowColor = this.coolGlowColors[coolIndex];
+ } else if (random < litRate) { // Warm color
+ state = 'on';
+ const warmIndex = Math.floor(Math.random() * this.warmColors.length);
+ color = this.warmColors[warmIndex];
+ glowColor = this.warmGlowColors[warmIndex];
+ }
+
+ if (state === 'off') {
+ return { state, style: {} };
+ }
+
+ const baseDuration = (color && this.coolColors.includes(color)) ? 3 : 4;
+ return {
+ state,
+ color,
+ glowColor,
+ style: {
+ 'background-color': color || '',
+ 'box-shadow': glowColor ? `0 0 6px ${glowColor}, 0 0 16px ${glowColor}35` : '',
+ '--wd': `${(Math.random() * 4 + baseDuration).toFixed(1)}s`,
+ '--wdl': `${-(Math.random() * 8).toFixed(1)}s`
+ }
+ };
+ }
+
+ private startWindowFlicker(): void {
+ this.flickerIntervalId = setInterval(() => {
+ this.randomFlicker();
+ }, 2800);
+ }
+
+ private stopWindowFlicker(): void {
+ if (this.flickerIntervalId === undefined) {
+ return;
+ }
+ clearInterval(this.flickerIntervalId);
+ this.flickerIntervalId = undefined;
+ }
+
+ private randomFlicker(): void {
+ const allWindows = Object.values(this.windows).flat();
+ if (allWindows.length === 0) {
+ return;
+ }
+
+ const pickCount = Math.floor(Math.random() * 6) + 1;
+ for (let i = 0; i < pickCount; i += 1) {
+ const target = allWindows[Math.floor(Math.random() * allWindows.length)];
+ if (!target) {
+ continue;
+ }
+
+ if (target.state === 'off') {
+ target.state = 'on';
+ const isCool = Math.random() < 0.5;
+ const colors = isCool ? this.coolColors : this.warmColors;
+ const glowColors = isCool ? this.coolGlowColors : this.warmGlowColors;
+ const index = Math.floor(Math.random() * colors.length);
+
+ target.color = colors[index];
+ target.glowColor = glowColors[index];
+ target.style = {
+ 'background-color': target.color || '',
+ 'box-shadow': target.glowColor ? `0 0 6px ${target.glowColor}, 0 0 16px ${target.glowColor}35` : '',
+ '--wd': `${(Math.random() * 4 + (isCool ? 3 : 4)).toFixed(1)}s`,
+ '--wdl': `${-(Math.random() * 8).toFixed(1)}s`
+ };
+ } else {
+ target.state = 'off';
+ target.color = undefined;
+ target.glowColor = undefined;
+ target.style = {};
+ }
+ }
}
}
diff --git a/src/app/services/auth-dialog.service.ts b/src/app/services/auth-dialog.service.ts
new file mode 100644
index 0000000..774b4b6
--- /dev/null
+++ b/src/app/services/auth-dialog.service.ts
@@ -0,0 +1,23 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject } from 'rxjs';
+
+export type AuthDialogState = 'login' | 'register' | null;
+
+@Injectable({ providedIn: 'root' })
+export class AuthDialogService {
+ private readonly dialogStateSubject = new BehaviorSubject(null);
+
+ readonly dialogState$ = this.dialogStateSubject.asObservable();
+
+ openLogin(): void {
+ this.dialogStateSubject.next('login');
+ }
+
+ openRegister(): void {
+ this.dialogStateSubject.next('register');
+ }
+
+ close(): void {
+ this.dialogStateSubject.next(null);
+ }
+}
\ No newline at end of file
diff --git a/src/app/services/auth.interceptor.ts b/src/app/services/auth.interceptor.ts
new file mode 100644
index 0000000..c878833
--- /dev/null
+++ b/src/app/services/auth.interceptor.ts
@@ -0,0 +1,22 @@
+import { HttpInterceptorFn } from '@angular/common/http';
+
+export const authInterceptor: HttpInterceptorFn = (req, next) => {
+ const token = localStorage.getItem('token');
+
+ // Add token to protected endpoints only (not registration or login)
+ const isProtectedEndpoint =
+ req.url.includes('/api/account/me') ||
+ req.url.includes('/api/account/bots') ||
+ req.url.includes('/api/account/official-bots') ||
+ req.url.includes('/api/challenge');
+
+ if (token && isProtectedEndpoint) {
+ req = req.clone({
+ setHeaders: {
+ Authorization: `Bearer ${token}`
+ }
+ });
+ }
+
+ return next(req);
+};
diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts
new file mode 100644
index 0000000..178b315
--- /dev/null
+++ b/src/app/services/auth.service.ts
@@ -0,0 +1,93 @@
+import { Injectable, inject } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { BehaviorSubject, Observable } from 'rxjs';
+import { map, tap } from 'rxjs/operators';
+import { environment } from '../../environments/environment';
+import { LoginRequest, RegisterRequest, RegisterResponse, LoginResponse, CurrentUser } from '../models/auth.models';
+
+@Injectable({ providedIn: 'root' })
+export class AuthService {
+ private readonly apiBase = environment.apiBaseUrl;
+ private readonly accountServiceUrl = environment.accountServiceUrl;
+ private readonly http = inject(HttpClient);
+
+ private currentUserSubject = new BehaviorSubject(null);
+ public currentUser$ = this.currentUserSubject.asObservable();
+
+ constructor() {
+ this.loadCurrentUser();
+ }
+
+ login(username: string, password: string): Observable {
+ return this.http
+ .post(`${this.accountServiceUrl}/api/account/login`, {
+ username,
+ password
+ })
+ .pipe(
+ tap((response) => {
+ localStorage.setItem('token', response.token);
+ localStorage.setItem('username', username);
+ // After login, fetch current user info
+ this.getCurrentUser().subscribe();
+ })
+ );
+ }
+
+ register(username: string, password: string, email?: string): Observable {
+ return this.http
+ .post(`${this.accountServiceUrl}/api/account`, {
+ username,
+ password,
+ email
+ })
+ .pipe(
+ tap((response) => {
+ localStorage.setItem('username', response.username);
+ localStorage.setItem('userId', response.id);
+ this.currentUserSubject.next({
+ id: response.id,
+ username: response.username,
+ rating: response.rating,
+ createdAt: response.createdAt
+ });
+ })
+ );
+ }
+
+ getCurrentUser(): Observable {
+ return this.http.get(`${this.accountServiceUrl}/api/account/me`).pipe(
+ tap((user) => {
+ localStorage.setItem('username', user.username);
+ localStorage.setItem('userId', user.id);
+ this.currentUserSubject.next(user);
+ })
+ );
+ }
+
+ logout(): void {
+ localStorage.removeItem('token');
+ localStorage.removeItem('username');
+ localStorage.removeItem('userId');
+ this.currentUserSubject.next(null);
+ }
+
+ isLoggedIn(): boolean {
+ return !!localStorage.getItem('token');
+ }
+
+ private loadCurrentUser(): void {
+ const token = localStorage.getItem('token');
+ const username = localStorage.getItem('username');
+ const userId = localStorage.getItem('userId');
+ if (token && username && userId) {
+ // Try to verify token is still valid by fetching current user
+ this.getCurrentUser().subscribe({
+ error: () => {
+ // Token is invalid, clear it
+ this.logout();
+ }
+ });
+ }
+ }
+}
diff --git a/src/app/services/theme.service.ts b/src/app/services/theme.service.ts
new file mode 100644
index 0000000..b450ae9
--- /dev/null
+++ b/src/app/services/theme.service.ts
@@ -0,0 +1,34 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject } from 'rxjs';
+
+@Injectable({ providedIn: 'root' })
+export class ThemeService {
+ private readonly darkModeSubject = new BehaviorSubject(false);
+
+ readonly darkMode$ = this.darkModeSubject.asObservable();
+
+ initTheme(): void {
+ const savedTheme = localStorage.getItem('theme');
+ this.applyDarkMode(savedTheme === 'dark');
+ }
+
+ toggleTheme(): void {
+ this.applyDarkMode(!this.darkModeSubject.value);
+ }
+
+ setDarkMode(isDarkMode: boolean): void {
+ this.applyDarkMode(isDarkMode);
+ }
+
+ private applyDarkMode(isDarkMode: boolean): void {
+ if (isDarkMode) {
+ document.documentElement.setAttribute('data-theme', 'dark');
+ localStorage.setItem('theme', 'dark');
+ } else {
+ document.documentElement.removeAttribute('data-theme');
+ localStorage.removeItem('theme');
+ }
+
+ this.darkModeSubject.next(isDarkMode);
+ }
+}
\ No newline at end of file
diff --git a/src/cityscape.html b/src/cityscape.html
new file mode 100644
index 0000000..e69de29
diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts
index e55757c..9ad1e10 100644
--- a/src/environments/environment.development.ts
+++ b/src/environments/environment.development.ts
@@ -1,6 +1,7 @@
export const environment = {
production: false,
apiBaseUrl: '',
+ accountServiceUrl: '',
wsBaseUrl: 'ws://localhost:8080',
apiPath: '/api/board/game'
};
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
index 42a2794..8c38733 100644
--- a/src/environments/environment.ts
+++ b/src/environments/environment.ts
@@ -1,6 +1,7 @@
export const environment = {
production: true,
apiBaseUrl: '',
+ accountServiceUrl: '',
wsBaseUrl: 'ws://localhost:8080',
apiPath: '/api/board/game'
};
diff --git a/src/styles-variables.css b/src/styles-variables.css
index 32b9dc3..8ff4919 100644
--- a/src/styles-variables.css
+++ b/src/styles-variables.css
@@ -1,6 +1,3 @@
-/* ========================================
- COLOR VARIABLES - Semantic Naming
- ======================================== */
/* Light Mode Colors (Default) */
:root:not([data-theme='dark']) {
@@ -22,11 +19,11 @@
--color-bg-input: #B9DAD1;
--color-bg-input-focus: #B9C2DA;
--color-bg-button: #C19EF5;
- --color-bg-button-hover: #BA6D4B;
+ --color-bg-button-hover: #ba4ba7;
--color-text-primary: #5A2C28;
--color-text-button-hover: #F3C8A0;
- --color-border: #5A2C28;
+ --color-border: #5a2843;
}
/* Dark Mode Colors */
@@ -122,4 +119,15 @@
SHADOWS
======================================== */
--shadow-md: 0 8px 24px rgba(90, 44, 40, 0.2);
+ /* Neon dialog / card variables (used by welcome dialogs, toolbar, login/register) */
+ --bb-bg: rgba(8, 6, 28, 0.92);
+ --bb-border: #00d5ff;
+ --bb-glow: 0 0 18px rgba(0, 210, 255, 0.5), inset 0 0 10px rgba(0, 210, 255, 0.05);
+ --bb-tag: #00d5ff;
+ --bb-title: #d4f4ff;
+ --btn-bg: #00d5ff;
+ --btn-fg: #04000f;
+ --btn-glow: 0 0 14px rgba(0, 210, 255, 0.9);
+ --dlg-bg: rgba(8, 6, 28, 0.95);
+ --dlg-border: #00d5ff;
}
\ No newline at end of file
diff --git a/src/styles.css b/src/styles.css
index fa5a81b..520eacd 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -62,3 +62,254 @@ button,
input {
font: inherit;
}
+
+.welcome-shell .import-game-form {
+ grid-column: 1 / -1;
+ background: var(--color-bg-card);
+ border: var(--border-width) solid var(--color-border);
+ border-radius: var(--border-radius-md);
+ padding: var(--size-lg-padding);
+ margin: var(--size-md) 0;
+ display: grid;
+ gap: var(--size-md);
+}
+
+.welcome-shell .import-game-form p {
+ margin: 0;
+ font-weight: 600;
+ color: var(--color-text-primary);
+}
+
+.welcome-shell .import-mode-group {
+ display: flex;
+ gap: var(--size-lg);
+ flex-wrap: wrap;
+}
+
+.welcome-shell .import-mode-option {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--size-sm);
+ font-weight: 600;
+ color: var(--color-text-primary);
+}
+
+.welcome-shell .import-mode-option input {
+ accent-color: var(--color-primary);
+}
+
+.welcome-shell .import-game-text {
+ width: 100%;
+ resize: vertical;
+ min-height: 110px;
+ border: var(--border-width) solid var(--color-border);
+ border-radius: var(--border-radius-md);
+ background: var(--color-bg-input);
+ color: var(--color-text-primary);
+ padding: var(--size-md-padding);
+}
+
+.welcome-shell .import-game-text:focus {
+ outline: none;
+ border-color: var(--color-secondary-mint);
+ box-shadow: 0 0 0 3px rgba(185, 218, 209, 0.2);
+}
+
+.welcome-shell .theme-toggle-container {
+ position: absolute;
+ top: 20px;
+ right: 20px;
+ z-index: 100;
+}
+
+.welcome-shell .switch {
+ display: inline-block;
+ position: relative;
+}
+
+.welcome-shell .switch__input {
+ clip: rect(1px, 1px, 1px, 1px);
+ clip-path: inset(50%);
+ height: 1px;
+ width: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+}
+
+.welcome-shell .switch__label {
+ position: relative;
+ display: inline-block;
+ width: 120px;
+ height: 60px;
+ background-color: #2B2B2B;
+ border: 5px solid #5B5B5B;
+ border-radius: 9999px;
+ cursor: pointer;
+ transition: all 0.4s cubic-bezier(.46,.03,.52,.96);
+}
+
+.welcome-shell .switch__indicator {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%) translateX(-72%);
+ display: block;
+ width: 40px;
+ height: 40px;
+ background-color: #7B7B7B;
+ border-radius: 9999px;
+ box-shadow: 10px 0px 0 0 rgba(0, 0, 0, 0.2) inset;
+ transition: all 0.4s cubic-bezier(.46,.03,.52,.96);
+}
+
+.welcome-shell .switch__indicator::before,
+.welcome-shell .switch__indicator::after {
+ position: absolute;
+ content: '';
+ display: block;
+ background-color: #FFFFFF;
+ border-radius: 9999px;
+ transition: all 0.4s cubic-bezier(.46,.03,.52,.96);
+}
+
+.welcome-shell .switch__indicator::before {
+ top: 7px;
+ left: 7px;
+ width: 9px;
+ height: 9px;
+ opacity: 0.6;
+}
+
+.welcome-shell .switch__indicator::after {
+ bottom: 8px;
+ right: 6px;
+ width: 14px;
+ height: 14px;
+ opacity: 0.8;
+}
+
+.welcome-shell .switch__decoration {
+ position: absolute;
+ top: 65%;
+ left: 50%;
+ display: block;
+ width: 5px;
+ height: 5px;
+ background-color: #FFFFFF;
+ border-radius: 9999px;
+ animation: twinkle-stars 0.8s infinite -0.6s;
+ transition: all 0.4s cubic-bezier(.46,.03,.52,.96);
+}
+
+.welcome-shell .switch__decoration::before,
+.welcome-shell .switch__decoration::after {
+ position: absolute;
+ display: block;
+ content: '';
+ width: 5px;
+ height: 5px;
+ background-color: #FFFFFF;
+ border-radius: 9999px;
+}
+
+.welcome-shell .switch__decoration::before {
+ top: -20px;
+ left: 10px;
+ opacity: 1;
+ animation: twinkle-stars 0.6s infinite;
+}
+
+.welcome-shell .switch__decoration::after {
+ top: -7px;
+ left: 30px;
+ animation: twinkle-stars 0.6s infinite -0.2s;
+}
+
+@keyframes twinkle-stars {
+ 50% {
+ opacity: 0.2;
+ }
+}
+
+.welcome-shell .switch__input:checked + .switch__label {
+ background-color: #8FB5F5;
+ border-color: #347CF8;
+}
+
+.welcome-shell .switch__input:checked + .switch__label .switch__indicator {
+ background-color: #ECD21F;
+ box-shadow: none;
+ transform: translate(-50%, -50%) translateX(72%);
+}
+
+.welcome-shell .switch__input:checked + .switch__label .switch__indicator::before,
+.welcome-shell .switch__input:checked + .switch__label .switch__indicator::after {
+ display: none;
+}
+
+.welcome-shell .switch__input:checked + .switch__label .switch__decoration {
+ top: 50%;
+ transform: translate(0%, -50%);
+ animation: cloud 8s linear infinite;
+ width: 20px;
+ height: 20px;
+}
+
+.welcome-shell .switch__input:checked + .switch__label .switch__decoration::before {
+ width: 10px;
+ height: 10px;
+ top: auto;
+ bottom: 0;
+ left: -8px;
+ animation: none;
+}
+
+.welcome-shell .switch__input:checked + .switch__label .switch__decoration::after {
+ width: 15px;
+ height: 15px;
+ top: auto;
+ bottom: 0;
+ left: 16px;
+ animation: none;
+}
+
+.welcome-shell .switch__input:checked + .switch__label .switch__decoration,
+.welcome-shell .switch__input:checked + .switch__label .switch__decoration::before,
+.welcome-shell .switch__input:checked + .switch__label .switch__decoration::after {
+ border-radius: 9999px 9999px 0 0;
+}
+
+.welcome-shell .switch__input:checked + .switch__label .switch__decoration::after {
+ border-bottom-right-radius: 9999px;
+}
+
+@keyframes cloud {
+ 0% {
+ transform: translate(0%, -50%);
+ }
+ 50% {
+ transform: translate(-50%, -50%);
+ }
+ 100% {
+ transform: translate(0%, -50%);
+ }
+}
+
+@media (max-width: 768px) {
+ .welcome-shell .theme-toggle-container {
+ top: 10px;
+ right: 10px;
+ }
+
+ .welcome-shell .switch__label {
+ width: 100px;
+ height: 50px;
+ }
+
+ .welcome-shell .switch__indicator {
+ width: 33px;
+ height: 33px;
+ }
+}