32 Commits

Author SHA1 Message Date
shahdlala66 7de4f3784b fix: path problem
Co-authored-by: Copilot <copilot@github.com>
2026-05-06 09:19:03 +02:00
shahdlala66 04df2250e9 feat: dockerfile added 2026-05-06 09:07:33 +02:00
shahdlala66 99100a3086 fix: no more build errors 2026-05-06 08:59:36 +02:00
Lala, Shahd e3466bda30 feat: game starts after accept 2026-05-05 22:25:49 +00:00
Lala, Shahd 550db1401b feat: challange request, accept, decline, page, added (not complete, polling issues, and not stable, and too much code for nothing) 2026-05-05 22:14:45 +00:00
Lala, Shahd 6a79be45bf feat: challange request, accept, decline, page, added (not complete, polling issues, and not stable, and too much code for nothing)
Co-authored-by: Copilot <copilot@github.com>
2026-05-05 22:14:20 +00:00
Lala, Shahd 82bf006f18 fix: proxy issues
Co-authored-by: Copilot <copilot@github.com>
2026-05-05 19:42:27 +00:00
Lala, Shahd 13edcb2f69 chore(merge): sync main into feat-ncs-66 2026-05-05 19:24:14 +00:00
Lala, Shahd 49d6aae1db feat: copy username 2026-05-05 18:52:12 +00:00
Lala, Shahd eadcd770ba feat: profile info view (player ID, rating etc) 2026-05-04 21:43:12 +00:00
shahdlala66 a3255602b3 feat: me tab added 2026-05-04 19:48:55 +02:00
Lala, Shahd d471eef7af feat: abstract buttons 2026-05-04 09:56:09 +02:00
Lala, Shahd 361ce1e817 feat: login and register, style is not ready 2026-05-03 20:49:08 +00:00
Lala, Shahd aa70083aed feat: login and register, style is not ready 2026-05-03 20:44:01 +00:00
shahdlala66 3b757d7ff7 feat: added a cat not sure about it tho 2026-04-28 22:45:37 +02:00
shahdlala66 19f3359106 style: more colors for windows 2026-04-26 22:13:33 +02:00
shahdlala66 d676288315 style: changed colors etc 2026-04-26 22:13:26 +02:00
shahdlala66 671886781e feat: new boared design 2026-04-26 13:34:55 +02:00
shahdlala66 dc9a7b2e32 feat: drag and drop 2026-04-22 13:16:44 +02:00
shahdlala66 5951257c99 feat: move history, export import fixed, timer added 2026-04-22 13:05:09 +02:00
shahdlala66 a59e2a023b style: bigger gifs 2026-04-22 09:31:15 +02:00
shahdlala66 4f76bcc7c6 feat: piece pormotion 2026-04-22 08:35:58 +02:00
shahdlala66 25b69fd7b6 feat: clean ups and shorter files 2026-04-22 08:28:16 +02:00
shahdlala66 c18026bce6 feat: added dark and light mode 2026-04-22 08:19:16 +02:00
shahdlala66 91fa247696 style: added new gifs (optianl) 2026-04-21 15:12:41 +02:00
shahdlala66 97365371c8 feat: new spec 2026-04-21 13:40:48 +02:00
shahdlala66 fdc0f1d73b feat: join pervious game added 2026-04-19 11:14:14 +02:00
shahdlala66 bc644c16e3 feat: 1vs BOT 2026-04-19 01:06:13 +02:00
shahdlala66 5497997455 feat: more components 2026-04-19 01:00:14 +02:00
shahdlala66 53459648c6 style: styles file added 2026-04-19 00:24:43 +02:00
shahdlala66 8c97a726a7 fix: removed repetitve code 2026-04-19 00:12:13 +02:00
shahdlala66 2582f8e4d6 style: brought back old style for text input 2026-04-19 00:05:52 +02:00
120 changed files with 6195 additions and 980 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
tmp
*.log
.git
.gitignore
Dockerfile
docker-compose*.yml
^\.env$
.idea
vscode
coverage
+19
View File
@@ -0,0 +1,19 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --silent
COPY . .
RUN npm run build -- --configuration production
FROM nginx:stable-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/nowchess-frontend/browser /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+22 -9
View File
@@ -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": [
@@ -42,12 +48,12 @@
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
"maximumError": "4MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
"maximumWarning": "1.5MB",
"maximumError": "2MB"
}
],
"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": [
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before

Width:  |  Height:  |  Size: 518 KiB

After

Width:  |  Height:  |  Size: 518 KiB

Before

Width:  |  Height:  |  Size: 4.8 MiB

After

Width:  |  Height:  |  Size: 4.8 MiB

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before

Width:  |  Height:  |  Size: 907 B

After

Width:  |  Height:  |  Size: 907 B

Before

Width:  |  Height:  |  Size: 919 B

After

Width:  |  Height:  |  Size: 919 B

Before

Width:  |  Height:  |  Size: 818 B

After

Width:  |  Height:  |  Size: 818 B

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before

Width:  |  Height:  |  Size: 161 B

After

Width:  |  Height:  |  Size: 161 B

Before

Width:  |  Height:  |  Size: 188 B

After

Width:  |  Height:  |  Size: 188 B

Before

Width:  |  Height:  |  Size: 188 B

After

Width:  |  Height:  |  Size: 188 B

Before

Width:  |  Height:  |  Size: 237 B

After

Width:  |  Height:  |  Size: 237 B

Before

Width:  |  Height:  |  Size: 243 B

After

Width:  |  Height:  |  Size: 243 B

Before

Width:  |  Height:  |  Size: 264 B

After

Width:  |  Height:  |  Size: 264 B

Before

Width:  |  Height:  |  Size: 244 B

After

Width:  |  Height:  |  Size: 244 B

Before

Width:  |  Height:  |  Size: 240 B

After

Width:  |  Height:  |  Size: 240 B

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 232 B

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 287 B

Before

Width:  |  Height:  |  Size: 211 B

After

Width:  |  Height:  |  Size: 211 B

Before

Width:  |  Height:  |  Size: 238 B

After

Width:  |  Height:  |  Size: 238 B

Before

Width:  |  Height:  |  Size: 227 B

After

Width:  |  Height:  |  Size: 227 B

Before

Width:  |  Height:  |  Size: 267 B

After

Width:  |  Height:  |  Size: 267 B

Before

Width:  |  Height:  |  Size: 300 B

After

Width:  |  Height:  |  Size: 300 B

Before

Width:  |  Height:  |  Size: 218 B

After

Width:  |  Height:  |  Size: 218 B

Before

Width:  |  Height:  |  Size: 244 B

After

Width:  |  Height:  |  Size: 244 B

Before

Width:  |  Height:  |  Size: 245 B

After

Width:  |  Height:  |  Size: 245 B

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 229 B

Before

Width:  |  Height:  |  Size: 286 B

After

Width:  |  Height:  |  Size: 286 B

Before

Width:  |  Height:  |  Size: 245 B

After

Width:  |  Height:  |  Size: 245 B

Before

Width:  |  Height:  |  Size: 266 B

After

Width:  |  Height:  |  Size: 266 B

Before

Width:  |  Height:  |  Size: 297 B

After

Width:  |  Height:  |  Size: 297 B

Before

Width:  |  Height:  |  Size: 258 B

After

Width:  |  Height:  |  Size: 258 B

Before

Width:  |  Height:  |  Size: 263 B

After

Width:  |  Height:  |  Size: 263 B

Before

Width:  |  Height:  |  Size: 313 B

After

Width:  |  Height:  |  Size: 313 B

Before

Width:  |  Height:  |  Size: 251 B

After

Width:  |  Height:  |  Size: 251 B

Before

Width:  |  Height:  |  Size: 275 B

After

Width:  |  Height:  |  Size: 275 B

Before

Width:  |  Height:  |  Size: 305 B

After

Width:  |  Height:  |  Size: 305 B

Before

Width:  |  Height:  |  Size: 281 B

After

Width:  |  Height:  |  Size: 281 B

Before

Width:  |  Height:  |  Size: 280 B

After

Width:  |  Height:  |  Size: 280 B

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

+16
View File
@@ -0,0 +1,16 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|woff2?|ttf|eot)$ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public";
}
}
+3 -24
View File
@@ -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"
}
}
}
+10
View File
@@ -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,
+3 -2
View File
@@ -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)
]
};
+2 -1
View File
@@ -1 +1,2 @@
<router-outlet />
<app-toolbar />
<router-outlet />
+4
View File
@@ -1,9 +1,13 @@
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';
import { ChallengesComponent } from './pages/challenges/challenges.component';
export const routes: Routes = [
{ path: '', component: WelcomeComponent },
{ path: 'profile', component: ProfileComponent },
{ path: 'challenges', component: ChallengesComponent },
{ path: 'game/:gameId', component: GameComponent },
{ path: '**', redirectTo: '' }
];
+11 -9
View File
@@ -1,21 +1,23 @@
import { Component, OnInit } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ToolbarComponent } from './components/toolbar/toolbar.component';
import { ThemeService } from './services/theme.service';
import { ChallengeWebSocketService } from './services/challenge-websocket.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 readonly challengeWs: ChallengeWebSocketService
) { }
private initTheme(): void {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
}
ngOnInit(): void {
this.themeService.initTheme();
this.challengeWs.connect();
}
}
+69
View File
@@ -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;
}
@@ -0,0 +1,261 @@
@import '../../button-template.css';
.challenge-create-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(3px);
&.loading {
pointer-events: none;
opacity: 0.7;
}
}
.challenge-create-dialog {
background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%);
border: 2px solid #00d5ff;
border-radius: 8px;
box-shadow: 0 0 20px rgba(0, 213, 255, 0.3), inset 0 0 10px rgba(0, 213, 255, 0.05);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
padding: 24px;
color: #e0e0e0;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid rgba(0, 213, 255, 0.2);
h2 {
margin: 0;
color: #00d5ff;
font-size: 24px;
font-weight: 600;
letter-spacing: 1px;
}
}
.close-btn {
background: none;
border: none;
font-size: 28px;
color: #00d5ff;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover:not(:disabled) {
color: #ffffff;
transform: scale(1.1);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.dialog-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
label {
color: #b0b0d0;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
input,
select {
background-color: rgba(15, 20, 50, 0.8);
border: 1px solid rgba(0, 213, 255, 0.3);
border-radius: 4px;
color: #e0e0e0;
padding: 10px 12px;
font-size: 14px;
font-family: 'Space Mono', monospace;
transition: all 0.2s ease;
&:focus {
outline: none;
border-color: #00d5ff;
box-shadow: 0 0 10px rgba(0, 213, 255, 0.3);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
option {
background-color: #0a0e27;
color: #e0e0e0;
}
}
small {
color: #ff6b6b;
font-size: 12px;
margin-top: 4px;
}
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.form-col {
display: flex;
flex-direction: column;
gap: 8px;
label {
color: #b0b0d0;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
input {
background-color: rgba(15, 20, 50, 0.8);
border: 1px solid rgba(0, 213, 255, 0.3);
border-radius: 4px;
color: #e0e0e0;
padding: 10px 12px;
font-size: 14px;
font-family: 'Space Mono', monospace;
transition: all 0.2s ease;
&:focus {
outline: none;
border-color: #00d5ff;
box-shadow: 0 0 10px rgba(0, 213, 255, 0.3);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.preset-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.preset-btn {
padding: 8px 16px;
background-color: rgba(0, 213, 255, 0.1);
border: 1px solid #00d5ff;
border-radius: 4px;
color: #00d5ff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background-color: rgba(0, 213, 255, 0.2);
box-shadow: 0 0 10px rgba(0, 213, 255, 0.3);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.error-message {
background-color: rgba(255, 107, 107, 0.1);
border: 1px solid #ff6b6b;
border-radius: 4px;
padding: 12px;
color: #ff9999;
font-size: 13px;
margin-bottom: 8px;
}
.dialog-buttons {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid rgba(0, 213, 255, 0.2);
button {
padding: 10px 24px;
font-size: 13px;
font-weight: 600;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
&.btn-secondary {
background-color: transparent;
border: 1px solid rgba(0, 213, 255, 0.3);
color: #00d5ff;
&:hover:not(:disabled) {
background-color: rgba(0, 213, 255, 0.1);
border-color: #00d5ff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&.btn-primary {
background-color: #00d5ff;
border: none;
color: #0a0e27;
&:hover:not(:disabled) {
box-shadow: 0 0 20px rgba(0, 213, 255, 0.6);
transform: translateY(-2px);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
}
@@ -0,0 +1,93 @@
<div class="challenge-create-dialog-overlay" (click)="cancel()" [class.loading]="loading">
<div class="challenge-create-dialog" (click)="$event.stopPropagation()">
<div class="dialog-header">
<h2>Create Challenge</h2>
<button type="button" class="close-btn" (click)="cancel()" [disabled]="loading">×</button>
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="dialog-form">
<!-- Error Message -->
<div *ngIf="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<!-- Target Username -->
<div class="form-group">
<label for="targetUsername">Opponent Username</label>
<input type="text" id="targetUsername" formControlName="targetUsername"
placeholder="Enter opponent's username" [disabled]="loading" required />
<small *ngIf="form.get('targetUsername')?.hasError('required') && form.get('targetUsername')?.touched">
Username is required
</small>
</div>
<!-- Player Color Selection -->
<div class="form-group">
<label for="color">Play as</label>
<select id="color" formControlName="color" [disabled]="loading">
<option value="white">White</option>
<option value="black">Black</option>
<option value="random">Random</option>
</select>
</div>
<!-- Time Control Mode Selection -->
<div class="form-group">
<label for="timeMode">Time Control</label>
<select id="timeMode" formControlName="timeMode" [disabled]="loading">
<option value="blitz">Blitz</option>
<option value="rapid">Rapid</option>
<option value="classical">Classical</option>
<option value="unlimited">Unlimited</option>
</select>
</div>
<!-- Time Presets -->
<div class="form-group" *ngIf="selectedTimeMode !== 'unlimited'">
<label>Presets</label>
<div class="preset-buttons">
<button type="button" *ngFor="let preset of getAvailablePresets()" class="preset-btn"
(click)="selectPreset(preset)" [disabled]="loading">
{{ preset.label }}
</button>
</div>
</div>
<!-- Custom Time Control -->
<div class="form-group" *ngIf="selectedTimeMode !== 'unlimited'">
<div class="form-row">
<div class="form-col">
<label for="limitMinutes">Time (minutes)</label>
<input type="number" id="limitMinutes" formControlName="limitMinutes" min="1" max="1000"
[disabled]="loading" />
</div>
<div class="form-col">
<label for="incrementSeconds">Increment (seconds)</label>
<input type="number" id="incrementSeconds" formControlName="incrementSeconds" min="0" max="300"
[disabled]="loading" />
</div>
</div>
</div>
<!-- TTL (Time to Live) -->
<div class="form-group">
<label for="ttlSeconds">Challenge Expires In</label>
<select id="ttlSeconds" formControlName="ttlSeconds" [disabled]="loading">
<option *ngFor="let ttl of ttlOptions" [value]="ttl.seconds">
{{ ttl.label }}
</option>
</select>
</div>
<!-- Buttons -->
<div class="dialog-buttons">
<button type="button" class="btn btn-secondary" (click)="cancel()" [disabled]="loading">
Cancel
</button>
<button type="submit" class="btn btn-primary" [disabled]="form.invalid || loading">
{{ loading ? 'Sending...' : 'Send Challenge' }}
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,159 @@
import { Component, inject, OnInit, OnDestroy, DestroyRef, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { finalize } from 'rxjs';
import { ChallengeService } from '../../services/challenge.service';
import { Router } from '@angular/router';
import { getErrorMessage } from '../../core/http/error-message.util';
import { PlayerColor } from '../../models/challenge.models';
type TimeMode = 'blitz' | 'rapid' | 'classical' | 'unlimited';
interface TimePreset {
label: string;
limitSeconds: number;
incrementSeconds: number;
}
@Component({
selector: 'app-challenge-create-dialog',
standalone: true,
imports: [CommonModule, FormsModule, ReactiveFormsModule],
templateUrl: './challenge-create-dialog.component.html',
styleUrls: ['./challenge-create-dialog.component.css']
})
export class ChallengeCreateDialogComponent implements OnInit, OnDestroy {
private readonly challengeService = inject(ChallengeService);
private readonly router = inject(Router);
private readonly fb = inject(FormBuilder);
private readonly destroyRef = inject(DestroyRef);
@Output() closeChallengeDialog = new EventEmitter<void>();
form!: FormGroup;
loading = false;
errorMessage = '';
selectedTimeMode: TimeMode = 'rapid';
timePresets: Record<TimeMode, TimePreset[]> = {
blitz: [
{ label: '1+0', limitSeconds: 60, incrementSeconds: 0 },
{ label: '2+1', limitSeconds: 120, incrementSeconds: 1 },
{ label: '3+0', limitSeconds: 180, incrementSeconds: 0 },
{ label: '3+2', limitSeconds: 180, incrementSeconds: 2 },
{ label: '5+0', limitSeconds: 300, incrementSeconds: 0 }
],
rapid: [
{ label: '10+0', limitSeconds: 600, incrementSeconds: 0 },
{ label: '10+5', limitSeconds: 600, incrementSeconds: 5 },
{ label: '15+10', limitSeconds: 900, incrementSeconds: 10 },
{ label: '25+10', limitSeconds: 1500, incrementSeconds: 10 }
],
classical: [
{ label: '30+0', limitSeconds: 1800, incrementSeconds: 0 },
{ label: '30+20', limitSeconds: 1800, incrementSeconds: 20 },
{ label: '60+30', limitSeconds: 3600, incrementSeconds: 30 },
{ label: '90+30', limitSeconds: 5400, incrementSeconds: 30 }
],
unlimited: []
};
ttlOptions = [
{ label: '5 minutes', seconds: 300 },
{ label: '1 hour', seconds: 3600 },
{ label: '1 day', seconds: 86400 },
{ label: 'No expiry', seconds: 0 }
];
ngOnInit(): void {
this.initializeForm();
}
ngOnDestroy(): void {
}
private initializeForm(): void {
this.form = this.fb.group({
targetUsername: ['', [Validators.required, Validators.minLength(1)]],
color: ['random', Validators.required],
timeMode: ['rapid'],
limitMinutes: [10, [Validators.required, Validators.min(1), Validators.max(1000)]],
incrementSeconds: [5, [Validators.required, Validators.min(0), Validators.max(300)]],
ttlSeconds: [3600, Validators.required]
});
this.form.get('timeMode')?.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((mode: unknown) => {
const timeMode = mode as TimeMode;
this.selectedTimeMode = timeMode;
if (timeMode !== 'unlimited') {
const firstPreset = this.timePresets[timeMode][0];
if (firstPreset) {
this.form.patchValue({
limitMinutes: firstPreset.limitSeconds / 60,
incrementSeconds: firstPreset.incrementSeconds
});
}
}
});
}
selectPreset(preset: TimePreset): void {
this.form.patchValue({
limitMinutes: preset.limitSeconds / 60,
incrementSeconds: preset.incrementSeconds
});
}
getAvailablePresets(): TimePreset[] {
return this.timePresets[this.selectedTimeMode] || [];
}
submit(): void {
if (this.form.invalid || this.loading) {
return;
}
const targetUsername = this.form.get('targetUsername')?.value?.trim();
if (!targetUsername) {
this.errorMessage = 'Please enter a valid username';
return;
}
this.errorMessage = '';
this.loading = true;
const limitSeconds = Math.round((this.form.get('limitMinutes')?.value || 0) * 60);
const incrementSeconds = this.form.get('incrementSeconds')?.value || 0;
const ttlSeconds = this.form.get('ttlSeconds')?.value;
const color = (this.form.get('color')?.value || 'random') as PlayerColor;
this.challengeService.sendChallenge(targetUsername, {
timeControl: {
limitSeconds,
incrementSeconds
},
color,
ttlSeconds: ttlSeconds > 0 ? ttlSeconds : undefined
})
.pipe(finalize(() => (this.loading = false)))
.subscribe({
next: (challenge) => {
// Challenge sent successfully - navigate to challenges page to view status
this.form.reset();
this.closeChallengeDialog.emit();
void this.router.navigate(['/challenges']);
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Failed to send challenge. Please try again.');
}
});
}
cancel(): void {
this.form.reset();
this.closeChallengeDialog.emit();
}
}
@@ -0,0 +1,194 @@
@import '../../button-template.css';
.challenge-notification {
position: fixed;
top: 20px;
right: 20px;
max-width: 400px;
background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%);
border: 2px solid #00d5ff;
border-radius: 8px;
box-shadow: 0 0 20px rgba(0, 213, 255, 0.4), inset 0 0 10px rgba(0, 213, 255, 0.05);
padding: 16px;
color: #e0e0e0;
z-index: 2000;
animation: slideInRight 0.3s ease-out;
&.error {
border-color: #ff6b6b;
box-shadow: 0 0 20px rgba(255, 107, 107, 0.4), inset 0 0 10px rgba(255, 107, 107, 0.05);
}
@media (max-width: 768px) {
top: 10px;
right: 10px;
left: 10px;
max-width: none;
}
}
@keyframes slideInRight {
from {
transform: translateX(450px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(0, 213, 255, 0.2);
}
.notification-title {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
.badge {
background-color: rgba(0, 213, 255, 0.2);
border: 1px solid #00d5ff;
border-radius: 3px;
padding: 4px 8px;
font-size: 10px;
font-weight: 700;
color: #00d5ff;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}
.title {
font-size: 14px;
font-weight: 600;
color: #d4f4ff;
}
}
.close-btn {
background: none;
border: none;
font-size: 24px;
color: #00d5ff;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover:not(:disabled) {
color: #ffffff;
transform: scale(1.15);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.notification-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.time-control {
display: flex;
gap: 8px;
align-items: center;
font-size: 13px;
.label {
color: #b0b0d0;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.value {
background-color: rgba(0, 213, 255, 0.1);
border: 1px solid rgba(0, 213, 255, 0.3);
border-radius: 3px;
padding: 4px 12px;
color: #00d5ff;
font-family: 'Space Mono', monospace;
font-weight: 600;
}
}
.expiration {
font-size: 12px;
color: #b0b0d0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.error-message {
background-color: rgba(255, 107, 107, 0.1);
border: 1px solid #ff6b6b;
border-radius: 3px;
padding: 8px 10px;
color: #ff9999;
font-size: 12px;
}
.notification-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
button {
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
border: none;
&.btn-decline {
background-color: transparent;
border: 1px solid rgba(0, 213, 255, 0.3);
color: #00d5ff;
&:hover:not(:disabled) {
background-color: rgba(0, 213, 255, 0.1);
border-color: #00d5ff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&.btn-accept {
background-color: #00d5ff;
color: #0a0e27;
&:hover:not(:disabled) {
box-shadow: 0 0 15px rgba(0, 213, 255, 0.6);
transform: translateY(-1px);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
}
@@ -0,0 +1,38 @@
<div class="challenge-notification" [class.error]="!!errorMessage">
<div class="notification-header">
<div class="notification-title">
<span class="badge">CHALLENGE</span>
<span class="title">{{ getCreatedByDisplay() }} challenged you!</span>
</div>
<button type="button" class="close-btn" (click)="onClose()"
[disabled]="acceptingChallenge || decliningChallenge">
×
</button>
</div>
<div class="notification-content">
<div class="time-control">
<span class="label">Time Control:</span>
<span class="value">{{ getTimeControlDisplay() }}</span>
</div>
<div class="expiration">
<span class="label">{{ getExpirationInfo() }}</span>
</div>
<div *ngIf="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<div class="notification-actions">
<button type="button" class="btn btn-decline" (click)="onDecline()"
[disabled]="acceptingChallenge || decliningChallenge">
{{ decliningChallenge ? 'Declining...' : 'Decline' }}
</button>
<button type="button" class="btn btn-accept" (click)="onAccept()"
[disabled]="acceptingChallenge || decliningChallenge">
{{ acceptingChallenge ? 'Accepting...' : 'Accept' }}
</button>
</div>
</div>
</div>
@@ -0,0 +1,101 @@
import { Component, Input, Output, EventEmitter, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Challenge } from '../../models/challenge.models';
import { ChallengeService } from '../../services/challenge.service';
import { finalize } from 'rxjs';
import { getErrorMessage } from '../../core/http/error-message.util';
@Component({
selector: 'app-challenge-notification',
standalone: true,
imports: [CommonModule],
templateUrl: './challenge-notification.component.html',
styleUrls: ['./challenge-notification.component.css']
})
export class ChallengeNotificationComponent {
@Input() challenge!: Challenge;
@Output() accept = new EventEmitter<Challenge>();
@Output() decline = new EventEmitter<Challenge>();
@Output() close = new EventEmitter<void>();
private readonly challengeService = inject(ChallengeService);
acceptingChallenge = false;
decliningChallenge = false;
errorMessage = '';
onAccept(): void {
if (this.acceptingChallenge || this.decliningChallenge) {
return;
}
this.acceptingChallenge = true;
this.errorMessage = '';
this.challengeService.acceptChallenge(this.challenge.id)
.pipe(finalize(() => (this.acceptingChallenge = false)))
.subscribe({
next: () => {
this.accept.emit(this.challenge);
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Failed to accept challenge');
}
});
}
onDecline(): void {
if (this.acceptingChallenge || this.decliningChallenge) {
return;
}
this.decliningChallenge = true;
this.errorMessage = '';
this.challengeService.declineChallenge(this.challenge.id, { reason: 'Not interested' })
.pipe(finalize(() => (this.decliningChallenge = false)))
.subscribe({
next: () => {
this.decline.emit(this.challenge);
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Failed to decline challenge');
}
});
}
onClose(): void {
this.close.emit();
}
getTimeControlDisplay(): string {
const { limit, increment } = this.challenge.timeControl;
if (!limit || !increment) {
return 'Unlimited';
}
const minutes = Math.floor(limit / 60);
return `${minutes}+${increment}`;
}
getCreatedByDisplay(): string {
return this.challenge.challenger.name;
}
getExpirationInfo(): string {
const expiresAt = new Date(this.challenge.expiresAt);
const now = new Date();
const diffMs = expiresAt.getTime() - now.getTime();
if (diffMs <= 0) {
return 'Expired';
}
const minutes = Math.floor(diffMs / 60000);
if (minutes > 60) {
const hours = Math.floor(minutes / 60);
return `Expires in ${hours}h`;
}
return `Expires in ${minutes}m`;
}
}
@@ -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 {
@@ -1,5 +1,5 @@
<div class="board-shell">
<div class="board-grid">
<div class="board-grid" [class.board-grid--classic]="boardTheme === 'classic'" [class.board-grid--arabian]="boardTheme === 'arabian'">
@for (square of squares; track trackByCoordinate($index, square)) {
<button
type="button"
@@ -8,13 +8,25 @@
[class.dark]="!square.isLight"
[class.selected]="isSelected(square)"
[class.highlighted]="isHighlighted(square)"
[class.drag-source]="isDraggingSource(square)"
[class.drag-over]="isDragOver(square)"
[attr.data-square]="square.coordinate"
(click)="onSquareClick(square)"
(dragover)="onSquareDragOver($event, square)"
(drop)="onSquareDrop($event, square)"
>
<app-chess-piece [pieceCode]="square.pieceCode" />
<app-chess-piece
[pieceCode]="square.pieceCode"
[boardTheme]="boardTheme"
[draggable]="!!square.pieceCode"
(pieceDragStart)="onPieceDragStart($event, square)"
(pieceDragEnd)="onSquareDragEnd()"
/>
</button>
}
</div>
<img class="board-bottom" src="/arabian-chess/sprites/board/board_bottom.png" alt="Board frame" />
@if (boardTheme === 'arabian') {
<img class="board-bottom" src="/assets/arabian-chess/sprites/board/board_bottom.png" alt="Board frame" />
}
</div>
@@ -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<string>();
squares: BoardSquare[] = [];
private highlightedSquareSet = new Set<string>();
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;
}
}
@@ -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) {
@@ -1,3 +1,10 @@
@if (pieceCode) {
<img class="piece" [src]="spriteUrl" [alt]="pieceCode" />
<img
class="piece"
[src]="spriteUrl"
[alt]="pieceCode"
[attr.draggable]="draggable ? 'true' : null"
(dragstart)="onDragStart($event)"
(dragend)="onDragEnd()"
/>
}
@@ -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<DragEvent>();
@Output() pieceDragEnd = new EventEmitter<void>();
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';
}
}
}
@@ -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);
@@ -2,30 +2,18 @@
<label>{{ label }}</label>
@if (inputType === 'textarea') {
<textarea
#textareaInput
[placeholder]="placeholder"
[value]="value"
[rows]="rows"
(input)="onValueChange(textareaInput.value)"
class="form-input"
></textarea>
<textarea #textareaInput [placeholder]="placeholder" [value]="value" [rows]="rows"
(input)="onValueChange(textareaInput.value)" class="form-input"></textarea>
} @else {
<input
#textInput
type="text"
[placeholder]="placeholder"
[value]="value"
(input)="onValueChange(textInput.value)"
class="form-input"
/>
<input #textInput type="text" [placeholder]="placeholder" [value]="value" (input)="onValueChange(textInput.value)"
class="form-input" />
}
<button type="button" class="btn w-100" (click)="onButtonClick()">
<button type="button" class="app-btn w-100" (click)="onButtonClick()">
{{ buttonLabel }}
</button>
@if (hintText) {
<p class="hint-text">{{ hintText }}</p>
<p class="hint-text">{{ hintText }}</p>
}
</section>
</section>
@@ -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;
}
@@ -0,0 +1,36 @@
<div class="dialog-overlay" (click)="closeDialog()">
<div class="dialog-card" (click)="$event.stopPropagation()">
<div class="dialog-title">LOGIN</div>
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<label for="username" class="sr-only">Username</label>
<input id="username" type="text" class="dialog-input" formControlName="username" placeholder="Username"
[disabled]="isLoading" />
@if (loginForm.get('username')?.invalid && loginForm.get('username')?.touched) {
<small class="text-danger">Username must be at least 3 characters</small>
}
<label for="password" class="sr-only">Password</label>
<input id="password" type="password" class="dialog-input" formControlName="password" placeholder="Password"
[disabled]="isLoading" />
@if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) {
<small class="text-danger">Password must be at least 6 characters</small>
}
@if (errorMessage) {
<div class="error-banner">{{ errorMessage }}</div>
}
<div class="dialog-actions">
<button type="button" class="app-btn" (click)="openRegister()">Create account</button>
<button type="button" class="app-btn" (click)="closeDialog()">Cancel</button>
<button type="submit" class="app-btn" [disabled]="isLoading || loginForm.invalid">
@if (isLoading) {
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
}
Login
</button>
</div>
</form>
</div>
</div>
@@ -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<void>();
@Output() onSuccess = new EventEmitter<void>();
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();
}
}
@@ -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;
}
}
@@ -2,25 +2,22 @@
<div class="promotion-dialog">
<div class="promotion-header">
<h3>Pawn Promotion</h3>
<button class="close-btn" (click)="close()" aria-label="Close">&times;</button>
<button class="app-btn" (click)="close()" aria-label="Close"
style="padding:0.2rem 0.5rem;min-width:unset;">&times;</button>
</div>
<div class="promotion-body">
<p class="promotion-prompt">Choose a piece to promote your pawn to:</p>
<div class="promotion-options">
@for (piece of promotionPieces; track piece.type) {
<button
class="promotion-button"
[attr.data-piece]="piece.type"
(click)="selectPromotion(piece.type)"
[title]="piece.label"
>
<span class="piece-symbol">{{ piece.symbol }}</span>
<span class="piece-label">{{ piece.label }}</span>
</button>
<button type="button" class="app-btn promotion-choice" [attr.data-piece]="piece.type"
(click)="selectPromotion(piece.type)" [title]="piece.label">
<span class="piece-symbol">{{ piece.symbol }}</span>
<span class="piece-label">{{ piece.label }}</span>
</button>
}
</div>
</div>
</div>
</div>
</div>
@@ -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;
}
@@ -0,0 +1,43 @@
<div class="dialog-overlay" (click)="closeDialog()">
<div class="dialog-card" (click)="$event.stopPropagation()">
<div class="dialog-title">CREATE ACCOUNT</div>
<form [formGroup]="registerForm" (ngSubmit)="onSubmit()">
<input id="username" type="text" class="dialog-input" formControlName="username" placeholder="Username"
[disabled]="isLoading" />
@if (registerForm.get('username')?.invalid && registerForm.get('username')?.touched) {
<small class="text-danger">Username must be at least 3 characters</small>
}
<input id="email" type="email" class="dialog-input" formControlName="email" placeholder="Email"
[disabled]="isLoading" />
@if (registerForm.get('email')?.invalid && registerForm.get('email')?.touched) {
<small class="text-danger">Please enter a valid email</small>
}
<input id="password" type="password" class="dialog-input" formControlName="password" placeholder="Password"
[disabled]="isLoading" />
@if (registerForm.get('password')?.invalid && registerForm.get('password')?.touched) {
<small class="text-danger">Password must be at least 6 characters</small>
}
<input id="confirmPassword" type="password" class="dialog-input" formControlName="confirmPassword"
placeholder="Confirm Password" [disabled]="isLoading" />
@if (errorMessage) {
<div class="error-banner">{{ errorMessage }}</div>
}
<div class="dialog-actions">
<button type="button" class="app-btn" (click)="openLogin()">Already have an account?</button>
<button type="button" class="app-btn" (click)="closeDialog()">Cancel</button>
<button type="submit" class="app-btn" [disabled]="isLoading || registerForm.invalid">
@if (isLoading) {
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
}
Register
</button>
</div>
</form>
</div>
</div>
@@ -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<void>();
@Output() onSuccess = new EventEmitter<void>();
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();
}
}
@@ -0,0 +1,229 @@
@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;
}
.notification-container {
position: relative;
}
.notification-badge {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
position: relative;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover {
transform: scale(1.1);
}
&.has-notifications {
animation: pulse 2s infinite;
}
.badge {
position: absolute;
top: -8px;
right: -8px;
background-color: #ff6b6b;
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
box-shadow: 0 0 10px rgba(255, 107, 107, 0.5);
}
}
@keyframes pulse {
0% {
filter: drop-shadow(0 0 3px rgba(0, 213, 255, 0.3));
}
50% {
filter: drop-shadow(0 0 8px rgba(0, 213, 255, 0.6));
}
100% {
filter: drop-shadow(0 0 3px rgba(0, 213, 255, 0.3));
}
}
.notification-menu {
position: absolute;
top: 100%;
right: 0;
background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%);
border: 1px solid rgba(0, 213, 255, 0.3);
border-radius: 8px;
min-width: 250px;
max-height: 300px;
overflow-y: auto;
z-index: 1001;
box-shadow: 0 0 20px rgba(0, 213, 255, 0.3);
margin-top: 10px;
.notification-menu-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border-bottom: 1px solid rgba(0, 213, 255, 0.2);
color: #00d5ff;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
.close-btn {
background: none;
border: none;
color: #00d5ff;
font-size: 20px;
cursor: pointer;
padding: 0;
transition: all 0.2s ease;
&:hover {
transform: scale(1.1);
}
}
}
.notification-list {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
}
.notification-item {
padding: 10px 12px;
background-color: rgba(0, 213, 255, 0.05);
border: 1px solid rgba(0, 213, 255, 0.1);
border-radius: 4px;
font-size: 12px;
color: #b0b0d0;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background-color: rgba(0, 213, 255, 0.1);
border-color: rgba(0, 213, 255, 0.3);
}
}
.notification-menu-footer {
padding: 8px 8px 0;
border-top: 1px solid rgba(0, 213, 255, 0.2);
}
.view-all-btn {
width: 100%;
padding: 8px 12px;
background-color: rgba(0, 213, 255, 0.1);
border: 1px solid rgba(0, 213, 255, 0.3);
border-radius: 4px;
color: #00d5ff;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background-color: rgba(0, 213, 255, 0.2);
border-color: #00d5ff;
}
}
}
@@ -0,0 +1,75 @@
<nav class="navbar">
<div class="container-fluid">
<span class="navbar-brand">NowChess</span>
<div class="ms-auto">
<div class="d-flex align-items-center gap-2">
<!-- Challenge Notification Badge -->
<div class="notification-container">
<button type="button" class="notification-badge" (click)="toggleNotificationMenu()"
[class.has-notifications]="incomingChallenges.length > 0" title="View challenges">
🔔
<span *ngIf="incomingChallenges.length > 0" class="badge">
{{ incomingChallenges.length }}
</span>
</button>
<!-- Notification Menu Dropdown -->
@if (showNotificationMenu && incomingChallenges.length > 0) {
<div class="notification-menu" (click)="$event.stopPropagation()">
<div class="notification-menu-header">
Challenges
<button type="button" class="close-btn" (click)="closeNotificationMenu()">×</button>
</div>
<div class="notification-list">
<div *ngFor="let challenge of incomingChallenges" class="notification-item">
{{ challenge.challenger.name }}
</div>
</div>
<div class="notification-menu-footer">
<button type="button" class="view-all-btn" (click)="goToChallenges()">
View All Challenges →
</button>
</div>
</div>
}
</div>
<button type="button" class="app-btn" (click)="toggleTheme()">
{{ isDarkMode ? 'Light mode' : 'Dark mode' }}
</button>
@if (currentUser; as user) {
<div class="d-flex align-items-center gap-2 user-section">
<button type="button" class="me-btn" (click)="goToProfile()">
👤 {{ user.username }}
</button>
<button type="button" class="app-btn" (click)="goToChallenges()">
Challenges
</button>
<button type="button" class="app-btn" (click)="logout()">Logout</button>
</div>
} @else {
<button type="button" class="app-btn" (click)="openLoginDialog()">
Login
</button>
<button type="button" class="app-btn" (click)="openRegisterDialog()">
Register
</button>
}
</div>
</div>
</div>
</nav>
<!-- Challenge Notification Popup -->
@if (displayedChallenge) {
<app-challenge-notification [challenge]="displayedChallenge" (accept)="onChallengeAccepted($event)"
(decline)="onChallengeDeclined($event)" (close)="onNotificationClose()" />
}
@if (showLoginDialog) {
<app-login-dialog (onClose)="closeLoginDialog()" (onSuccess)="onLoginSuccess()" />
}
@if (showRegisterDialog) {
<app-register-dialog (onClose)="closeRegisterDialog()" (onSuccess)="onRegisterSuccess()" />
}
@@ -0,0 +1,140 @@
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';
import { ChallengeEventService } from '../../services/challenge-event.service';
import { ChallengeNotificationComponent } from '../challenge-notification/challenge-notification.component';
import { Challenge } from '../../models/challenge.models';
@Component({
selector: 'app-toolbar',
standalone: true,
imports: [CommonModule, LoginDialogComponent, RegisterDialogComponent, ChallengeNotificationComponent],
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 challengeEventService = inject(ChallengeEventService);
private readonly router = inject(Router);
currentUser: CurrentUser | null = null;
showLoginDialog = false;
showRegisterDialog = false;
isDarkMode = false;
incomingChallenges: Challenge[] = [];
showNotificationMenu = false;
displayedChallenge: Challenge | null = null;
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;
});
this.challengeEventService.getIncomingChallenges$()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((challenges) => {
this.incomingChallenges = challenges;
// Show the most recent challenge as notification
if (challenges.length > 0) {
this.displayedChallenge = challenges[0];
}
});
this.challengeEventService.getChallengeReceived$()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((challenge) => {
this.displayedChallenge = challenge;
});
}
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();
}
toggleNotificationMenu(): void {
this.showNotificationMenu = !this.showNotificationMenu;
}
closeNotificationMenu(): void {
this.showNotificationMenu = false;
}
onChallengeAccepted(challenge: Challenge): void {
this.challengeEventService.onChallengeAccepted(challenge);
this.displayedChallenge = null;
this.closeNotificationMenu();
// Navigate to the game (once game creation is handled by backend)
}
onChallengeDeclined(challenge: Challenge): void {
this.challengeEventService.removeChallenge(challenge.id);
this.displayedChallenge = null;
this.closeNotificationMenu();
}
onNotificationClose(): void {
this.displayedChallenge = null;
}
goToChallenges(): void {
this.closeNotificationMenu();
void this.router.navigate(['/challenges']);
}
}
+28
View File
@@ -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;
}
+49
View File
@@ -0,0 +1,49 @@
export type ChallengeStatus = 'created' | 'pending' | 'accepted' | 'declined' | 'cancelled' | 'expired';
export type PlayerColor = 'white' | 'black' | 'random';
export interface Player {
id: string;
name: string;
rating: number;
}
export interface TimeControl {
type: string | null;
limit: number | null;
increment: number | null;
}
export interface Challenge {
id: string;
challenger: Player;
destUser: Player;
variant: string;
color: PlayerColor;
timeControl: TimeControl;
status: ChallengeStatus;
declineReason: string | null;
gameId: string | null;
expiresAt: string;
createdAt: string;
}
export interface SendChallengeRequest {
timeControl: {
limitSeconds: number;
incrementSeconds: number;
};
color?: PlayerColor;
ttlSeconds?: number;
}
export interface ListChallengesResponse {
'in'?: Challenge[];
'out'?: Challenge[];
incoming?: Challenge[];
outgoing?: Challenge[];
}
export interface DeclineChallengeRequest {
reason?: string;
}
@@ -0,0 +1,222 @@
@import '../../button-template.css';
.challenges-container {
min-height: 100vh;
background: linear-gradient(135deg, #04000f 0%, #0e0235 25%, #2d0860 50%, #0e0235 75%, #04000f 100%);
color: #e0e0e0;
padding: 20px;
font-family: 'Space Mono', 'Courier New', monospace;
}
.challenges-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid rgba(0, 213, 255, 0.3);
h1 {
margin: 0;
color: #00d5ff;
font-size: 32px;
letter-spacing: 2px;
text-transform: uppercase;
}
}
.back-btn {
background: transparent;
border: 1px solid rgba(0, 213, 255, 0.3);
color: #00d5ff;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s ease;
&:hover {
background-color: rgba(0, 213, 255, 0.1);
border-color: #00d5ff;
}
}
.error-banner {
background-color: rgba(255, 107, 107, 0.1);
border: 1px solid #ff6b6b;
border-radius: 4px;
padding: 15px;
color: #ff9999;
margin-bottom: 20px;
}
.challenges-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
@media (max-width: 1024px) {
grid-template-columns: 1fr;
}
}
.challenges-section {
h2 {
color: #00d5ff;
font-size: 20px;
margin: 0 0 20px 0;
padding-bottom: 10px;
border-bottom: 1px solid rgba(0, 213, 255, 0.2);
text-transform: uppercase;
letter-spacing: 1px;
}
}
.loading-spinner {
text-align: center;
padding: 40px;
color: #00d5ff;
font-size: 16px;
}
.empty-state {
text-align: center;
padding: 40px;
color: #b0b0d0;
font-size: 14px;
background-color: rgba(0, 213, 255, 0.05);
border: 1px dashed rgba(0, 213, 255, 0.2);
border-radius: 4px;
}
.challenge-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.challenge-card {
background: linear-gradient(135deg, rgba(10, 14, 39, 0.8) 0%, rgba(26, 26, 62, 0.8) 100%);
border: 1px solid rgba(0, 213, 255, 0.3);
border-radius: 8px;
padding: 16px;
transition: all 0.2s ease;
&:hover {
border-color: rgba(0, 213, 255, 0.6);
box-shadow: 0 0 15px rgba(0, 213, 255, 0.2);
}
}
.challenge-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(0, 213, 255, 0.1);
.challenger-name {
color: #d4f4ff;
font-weight: 600;
font-size: 14px;
}
.time-control {
background-color: rgba(0, 213, 255, 0.15);
border: 1px solid rgba(0, 213, 255, 0.3);
color: #00d5ff;
padding: 4px 12px;
border-radius: 3px;
font-weight: 600;
font-size: 12px;
}
}
.challenge-details {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
.detail {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
.label {
color: #b0b0d0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.value {
color: #00d5ff;
font-weight: 600;
&.status-pending {
color: #ffcc30;
}
&.status-accepted {
color: #4ade80;
}
&.status-declined {
color: #ff6b6b;
}
&.status-expired {
color: #b0b0d0;
}
}
}
}
.challenge-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
.btn {
padding: 8px 16px;
border: 1px solid rgba(0, 213, 255, 0.3);
border-radius: 4px;
background-color: transparent;
color: #00d5ff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all 0.2s ease;
&:hover {
background-color: rgba(0, 213, 255, 0.1);
border-color: #00d5ff;
}
&.btn-accept {
background-color: #00d5ff;
color: #04000f;
border: none;
&:hover {
box-shadow: 0 0 15px rgba(0, 213, 255, 0.6);
}
}
&.btn-decline,
&.btn-cancel {
color: #ff6b6b;
border-color: rgba(255, 107, 107, 0.3);
&:hover {
background-color: rgba(255, 107, 107, 0.1);
border-color: #ff6b6b;
}
}
}
}
@@ -0,0 +1,102 @@
<div class="challenges-container">
<div class="challenges-header">
<h1>Active Challenges</h1>
<button type="button" class="back-btn" (click)="goBack()">← Back</button>
</div>
<div *ngIf="errorMessage" class="error-banner">
{{ errorMessage }}
</div>
<div class="challenges-grid">
<!-- Incoming Challenges -->
<div class="challenges-section">
<h2>Incoming Challenges</h2>
<div *ngIf="loading" class="loading-spinner">Loading...</div>
<div *ngIf="!loading && incomingChallenges.length === 0" class="empty-state">
<p>No incoming challenges</p>
</div>
<div *ngIf="!loading && incomingChallenges.length > 0" class="challenge-list">
<div *ngFor="let challenge of incomingChallenges" class="challenge-card">
<div class="challenge-header">
<span class="challenger-name">{{ getChallengerDisplay(challenge) }}</span>
<span class="time-control">{{ getTimeControlDisplay(challenge) }}</span>
</div>
<div class="challenge-details">
<div class="detail">
<span class="label">Status:</span>
<span class="value" [class]="'status-' + challenge.status">
{{ challenge.status | uppercase }}
</span>
</div>
<div class="detail">
<span class="label">Expires in:</span>
<span class="value">{{ getExpirationInfo(challenge) }}</span>
</div>
</div>
<div class="challenge-actions" *ngIf="challenge.status === 'created'">
<button type="button" class="btn btn-decline" (click)="declineChallenge(challenge)">
Decline
</button>
<button type="button" class="btn btn-accept" (click)="acceptChallenge(challenge)">
Accept
</button>
</div>
<div class="challenge-actions" *ngIf="challenge.status === 'accepted' && challenge.gameId">
<button type="button" class="btn btn-accept" (click)="openGame(challenge)">
Play
</button>
</div>
</div>
</div>
</div>
<!-- Outgoing Challenges -->
<div class="challenges-section">
<h2>Outgoing Challenges</h2>
<div *ngIf="!loading && outgoingChallenges.length === 0" class="empty-state">
<p>No outgoing challenges</p>
</div>
<div *ngIf="!loading && outgoingChallenges.length > 0" class="challenge-list">
<div *ngFor="let challenge of outgoingChallenges" class="challenge-card">
<div class="challenge-header">
<span class="challenger-name">→ {{ getOpponentDisplay(challenge) }}</span>
<span class="time-control">{{ getTimeControlDisplay(challenge) }}</span>
</div>
<div class="challenge-details">
<div class="detail">
<span class="label">Status:</span>
<span class="value" [class]="'status-' + challenge.status">
{{ challenge.status | uppercase }}
</span>
</div>
<div class="detail">
<span class="label">Expires in:</span>
<span class="value">{{ getExpirationInfo(challenge) }}</span>
</div>
</div>
<div class="challenge-actions" *ngIf="challenge.status === 'created'">
<button type="button" class="btn btn-cancel" (click)="cancelChallenge(challenge)">
Cancel
</button>
</div>
<div class="challenge-actions" *ngIf="challenge.status === 'accepted' && challenge.gameId">
<button type="button" class="btn btn-accept" (click)="openGame(challenge)">
Play
</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,179 @@
import { Component, inject, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ChallengeService } from '../../services/challenge.service';
import { ChallengeEventService } from '../../services/challenge-event.service';
import { Challenge } from '../../models/challenge.models';
import { getErrorMessage } from '../../core/http/error-message.util';
@Component({
selector: 'app-challenges',
standalone: true,
imports: [CommonModule],
templateUrl: './challenges.component.html',
styleUrls: ['./challenges.component.css']
})
export class ChallengesComponent implements OnInit, OnDestroy {
private readonly challengeService = inject(ChallengeService);
private readonly challengeEventService = inject(ChallengeEventService);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
incomingChallenges: Challenge[] = [];
outgoingChallenges: Challenge[] = [];
loading = false;
errorMessage = '';
private pollInterval: any = null;
private readonly pollIntervalMs = 5000; // Poll every 5 seconds
ngOnInit(): void {
this.loadChallenges(true);
// Subscribe to challenge events
this.challengeEventService.getChallengeReceived$()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.loadChallenges();
});
// Start polling for challenge updates
this.startPolling();
}
ngOnDestroy(): void {
this.stopPolling();
}
private startPolling(): void {
this.pollInterval = setInterval(() => {
this.loadChallenges(false);
}, this.pollIntervalMs);
}
private stopPolling(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
loadChallenges(showLoader = false): void {
if (showLoader) {
this.loading = true;
this.errorMessage = '';
}
this.challengeService.listChallenges()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (response) => {
this.incomingChallenges = response.in || response.incoming || [];
this.outgoingChallenges = response.out || response.outgoing || [];
if (showLoader) {
this.loading = false;
}
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Failed to load challenges');
if (showLoader) {
this.loading = false;
}
}
});
}
acceptChallenge(challenge: Challenge): void {
this.challengeService.acceptChallenge(challenge.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (acceptedChallenge) => {
this.challengeEventService.onChallengeAccepted(acceptedChallenge);
this.loadChallenges();
if (acceptedChallenge.gameId) {
void this.router.navigate(['/game', acceptedChallenge.gameId]);
} else {
this.errorMessage = 'Challenge accepted, but no game was created.';
}
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Failed to accept challenge');
}
});
}
declineChallenge(challenge: Challenge): void {
this.challengeService.declineChallenge(challenge.id, { reason: 'Not interested' })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.challengeEventService.removeChallenge(challenge.id);
this.loadChallenges();
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Failed to decline challenge');
}
});
}
cancelChallenge(challenge: Challenge): void {
this.challengeService.cancelChallenge(challenge.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.loadChallenges();
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Failed to cancel challenge');
}
});
}
goBack(): void {
void this.router.navigate(['/']);
}
openGame(challenge: Challenge): void {
if (!challenge.gameId) {
this.errorMessage = 'Missing game id for this challenge.';
return;
}
void this.router.navigate(['/game', challenge.gameId]);
}
getTimeControlDisplay(challenge: Challenge): string {
const { limit, increment } = challenge.timeControl;
if (!limit || !increment) {
return 'Unlimited';
}
const minutes = Math.floor(limit / 60);
return `${minutes}+${increment}`;
}
getChallengerDisplay(challenge: Challenge): string {
return challenge.challenger.name;
}
getOpponentDisplay(challenge: Challenge): string {
return challenge.destUser.name;
}
getExpirationInfo(challenge: Challenge): string {
const expiresAt = new Date(challenge.expiresAt);
const now = new Date();
const diffMs = expiresAt.getTime() - now.getTime();
if (diffMs <= 0 || challenge.status === 'expired') {
return 'Expired';
}
const minutes = Math.floor(diffMs / 60000);
if (minutes > 60) {
const hours = Math.floor(minutes / 60);
return `${hours}h`;
}
return `${minutes}m`;
}
}
+216
View File
@@ -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 {

Some files were not shown because too many files have changed in this diff Show More