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)) { }
- Board frame + @if (boardTheme === 'arabian') { + Board frame + }
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 { - + } - @if (hintText) { -

{{ hintText }}

+

{{ hintText }}

} - + \ No newline at end of file diff --git a/src/app/components/login-dialog/login-dialog.component.css b/src/app/components/login-dialog/login-dialog.component.css new file mode 100644 index 0000000..acea1e8 --- /dev/null +++ b/src/app/components/login-dialog/login-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/login-dialog/login-dialog.component.html b/src/app/components/login-dialog/login-dialog.component.html new file mode 100644 index 0000000..0f3d9e4 --- /dev/null +++ b/src/app/components/login-dialog/login-dialog.component.html @@ -0,0 +1,36 @@ +
+
+
LOGIN
+ +
+ + + @if (loginForm.get('username')?.invalid && loginForm.get('username')?.touched) { + Username must be at least 3 characters + } + + + + @if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) { + Password must be at least 6 characters + } + + @if (errorMessage) { +
{{ errorMessage }}
+ } + +
+ + + +
+
+
+
\ No newline at end of file diff --git a/src/app/components/login-dialog/login-dialog.component.ts b/src/app/components/login-dialog/login-dialog.component.ts new file mode 100644 index 0000000..25a74a2 --- /dev/null +++ b/src/app/components/login-dialog/login-dialog.component.ts @@ -0,0 +1,62 @@ +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-login-dialog', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './login-dialog.component.html', + styleUrl: './login-dialog.component.css' +}) +export class LoginDialogComponent { + @Output() onClose = new EventEmitter(); + @Output() onSuccess = new EventEmitter(); + + private readonly authService = inject(AuthService); + private readonly authDialogService = inject(AuthDialogService); + private readonly formBuilder = inject(FormBuilder); + + loginForm: FormGroup; + errorMessage: string | null = null; + isLoading = false; + + constructor() { + this.loginForm = this.formBuilder.group({ + username: ['', [Validators.required, Validators.minLength(3)]], + password: ['', [Validators.required, Validators.minLength(6)]] + }); + } + + onSubmit(): void { + if (this.loginForm.invalid) { + this.errorMessage = 'Please fill in all fields correctly'; + return; + } + + this.isLoading = true; + this.errorMessage = null; + + const { username, password } = this.loginForm.value; + this.authService.login(username, password).subscribe({ + next: () => { + this.isLoading = false; + this.onSuccess.emit(); + }, + error: (err) => { + this.isLoading = false; + this.errorMessage = err.error?.message || 'Login failed. Please try again.'; + } + }); + } + + closeDialog(): void { + this.onClose.emit(); + } + + openRegister(): void { + this.authDialogService.openRegister(); + } +} diff --git a/src/app/components/promotion-dialog/promotion-dialog.component.css b/src/app/components/promotion-dialog/promotion-dialog.component.css index 578f349..aedff18 100644 --- a/src/app/components/promotion-dialog/promotion-dialog.component.css +++ b/src/app/components/promotion-dialog/promotion-dialog.component.css @@ -1,3 +1,5 @@ +@import '../../button-template.css'; + .promotion-dialog-overlay { position: fixed; top: 0; @@ -20,9 +22,10 @@ } .promotion-dialog { - background: white; - border-radius: 8px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + background: var(--dlg-bg, white); + border: 1.5px solid var(--dlg-border, #ddd); + border-radius: 4px; + box-shadow: var(--bb-glow, 0 4px 16px rgba(0, 0, 0, 0.2)); max-width: 400px; width: 90%; animation: slideUp 0.3s ease; @@ -44,32 +47,14 @@ justify-content: space-between; align-items: center; padding: 20px; - border-bottom: 1px solid #e0e0e0; + border-bottom: 1px solid var(--bb-border, #e0e0e0); h3 { margin: 0; + font-family: 'Bebas Neue', sans-serif; font-size: 18px; font-weight: 600; - color: #333; - } - - .close-btn { - background: none; - border: none; - font-size: 24px; - cursor: pointer; - color: #999; - padding: 0; - width: 30px; - height: 30px; - display: flex; - align-items: center; - justify-content: center; - transition: color 0.2s; - - &:hover { - color: #333; - } + color: var(--bb-title, #333); } } @@ -80,7 +65,8 @@ .promotion-prompt { margin: 0 0 20px 0; text-align: center; - color: #666; + color: var(--bb-title); + opacity: 0.8; font-size: 14px; } @@ -89,43 +75,3 @@ grid-template-columns: 1fr 1fr; gap: 12px; } - -.promotion-button { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 8px; - padding: 16px; - background: #f5f5f5; - border: 2px solid #ddd; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s; - font-family: inherit; - - &:hover { - background: #e8e8e8; - border-color: #999; - transform: translateY(-2px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - } - - &:active { - transform: translateY(0); - box-shadow: none; - } - - .piece-symbol { - font-size: 32px; - line-height: 1; - } - - .piece-label { - font-size: 12px; - font-weight: 500; - color: #666; - text-transform: uppercase; - letter-spacing: 0.5px; - } -} diff --git a/src/app/components/promotion-dialog/promotion-dialog.component.html b/src/app/components/promotion-dialog/promotion-dialog.component.html index 7cdb899..420e0bc 100644 --- a/src/app/components/promotion-dialog/promotion-dialog.component.html +++ b/src/app/components/promotion-dialog/promotion-dialog.component.html @@ -2,25 +2,22 @@

Pawn Promotion

- +
- +

Choose a piece to promote your pawn to:

- +
@for (piece of promotionPieces; track piece.type) { - + }
-
+ \ 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 @@ +
+
+
CREATE ACCOUNT
+ +
+ + @if (registerForm.get('username')?.invalid && registerForm.get('username')?.touched) { + Username must be at least 3 characters + } + + + @if (registerForm.get('email')?.invalid && registerForm.get('email')?.touched) { + Please enter a valid email + } + + + @if (registerForm.get('password')?.invalid && registerForm.get('password')?.touched) { + Password must be at least 6 characters + } + + + + @if (errorMessage) { +
{{ errorMessage }}
+ } + +
+ + + +
+
+
+
\ 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 @@ -
- +
+
@@ -13,79 +10,126 @@
@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) { +
+

{{ facade.gameCompletionMessage }}

+

+ Start a new game +

+
+ } + @if (facade.isGameFinished && facade.gameCompletionMessage) { +
+

{{ facade.gameCompletionMessage }}

+

+ Start a new game +

+
+ } +
+
+ +
+
+

Timers

+
+

White

+

{{ formatTimer(whiteTimerSeconds) }}

+
+
+

Black

+

{{ formatTimer(blackTimerSeconds) }}

+
+
- } -
-
- -
- -
- -
-
-
- -
+ +
+
+
+ +
-
- +
+
+

Board Design

+
+ + + +
-
-
- -
- -
+ +
+
+ + + +
+
+

Move History

+ + @if (facade.state.moves.length === 0) { +

No moves yet.

+ } @else { +
    + @for (move of facade.state.moves; track $index) { +
  1. + {{ $index + 1 }}. + {{ move }} +
  2. + } +
+ } +
+ +
+

Export

+
+ + +
+ + + + + + @if (exportNotice) { +

{{ exportNotice }}

+ } +
+ } @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' }} +
+
+
+ + +
+
+
+
+ + + +
+ } +
+
+ +
+
+
+
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 @@ -
-
-
- - -
-
-
-
- Player One -
-
- Plane - Raf -
-
-
- Player Two +
+ +
+
+
+
+
+
+
+
+
+
+
+
-
-
-

Welcome to NowChess

-

Pick a mode to begin.

-
- +
+
+
- @if (showDifficultySelector) { -
-

Select difficulty:

-
- - - +
+
+
+
+
+
- } - - - - -
- - @if (showJoinGameForm) { -
-

Enter the game ID:

-
- - - +
+
+
+
+
+
+
+
+
JOIN
+
JOIN
GAME
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ OPEN 24/7 +
+
+
BOT
+
PLAY WITH
A BOT
+ +
+
+
+
+
+
+
+ +
+ Player One +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
WELCOME
+
WELCOME TO
NOWCHESS
+
Play your next move from the skyline.
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ MORE +
+
+
OPTIONS
+
MORE
OPTIONS
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + + @if (showSpeechBubble) { +
+
+
{{ bubbleMessage }}
+
+
+
} - @if (errorMessage) { -

{{ errorMessage }}

+ + @if (isZoomedIn) { +
+
+
+
+ Player 2 + @if (showSecondSpeechBubble) { +
+
Feed me! 🍖
+
+
+ } + @if (showHappyBubble) { +
+
Happy meow! 😸
+
+
+ } +
+
+ + + @if (showMeatEmoji) { +
+ 🍖 +
+ } +
+
} -
-
+ +
+
+ + + @if (showDifficultyDialog) { +
+
+
SELECT DIFFICULTY
+
+ + + +
+
+
+ } + + @if (showOptionsDialog) { +
+
+
MORE OPTIONS
+
+ +
+
+
+ } + + @if (showJoinDialog) { +
+
+
JOIN GAME
+ +
+ + +
+
+
+ } + + @if (showImportDialog) { +
+
+
IMPORT GAME
+
+ + +
+ +
+ + +
+
+
+ } + + @if (errorMessage) { +

{{ errorMessage }}

+ } + \ 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; + } +}