From aa70083aed822fc07918bc461777eb48fa5624e8 Mon Sep 17 00:00:00 2001 From: "Lala, Shahd" Date: Sun, 3 May 2026 20:44:01 +0000 Subject: [PATCH] feat: login and register, style is not ready --- .devcontainer/devcontainer-lock.json | 9 + .devcontainer/devcontainer.json | 25 ++ angular.json | 7 +- package-lock.json | 27 +- proxy.conf.json | 12 +- src/app/app.config.ts | 5 +- src/app/app.html | 1 + src/app/app.ts | 15 +- .../login-dialog/login-dialog.component.css | 97 ++++++ .../login-dialog/login-dialog.component.html | 36 +++ .../login-dialog/login-dialog.component.ts | 62 ++++ .../register-dialog.component.css | 97 ++++++ .../register-dialog.component.html | 43 +++ .../register-dialog.component.ts | 71 +++++ .../components/toolbar/toolbar.component.css | 19 ++ .../components/toolbar/toolbar.component.html | 33 +++ .../components/toolbar/toolbar.component.ts | 81 +++++ src/app/models/auth.models.ts | 28 ++ src/app/pages/welcome/welcome.component.html | 262 ++++++----------- src/app/pages/welcome/welcome.component.ts | 276 +++++++++++------- src/app/services/auth-dialog.service.ts | 23 ++ src/app/services/auth.interceptor.ts | 22 ++ src/app/services/auth.service.ts | 93 ++++++ src/app/services/theme.service.ts | 34 +++ src/environments/environment.development.ts | 1 + src/environments/environment.ts | 1 + src/styles-variables.css | 11 + 27 files changed, 1083 insertions(+), 308 deletions(-) create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 .devcontainer/devcontainer.json create mode 100644 src/app/components/login-dialog/login-dialog.component.css create mode 100644 src/app/components/login-dialog/login-dialog.component.html create mode 100644 src/app/components/login-dialog/login-dialog.component.ts create mode 100644 src/app/components/register-dialog/register-dialog.component.css create mode 100644 src/app/components/register-dialog/register-dialog.component.html create mode 100644 src/app/components/register-dialog/register-dialog.component.ts create mode 100644 src/app/components/toolbar/toolbar.component.css create mode 100644 src/app/components/toolbar/toolbar.component.html create mode 100644 src/app/components/toolbar/toolbar.component.ts create mode 100644 src/app/models/auth.models.ts create mode 100644 src/app/services/auth-dialog.service.ts create mode 100644 src/app/services/auth.interceptor.ts create mode 100644 src/app/services/auth.service.ts create mode 100644 src/app/services/theme.service.ts diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..d5fba4b --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/devcontainer-config/features/dot-config:4": { + "version": "4.0.0", + "resolved": "ghcr.io/devcontainer-config/features/dot-config@sha256:d9ca770ccb218e619d5725fd50f00dfacdf582d92a8c3901f11b53b1caddd878", + "integrity": "sha256:d9ca770ccb218e619d5725fd50f00dfacdf582d92a8c3901f11b53b1caddd878" + } + } +} \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..e5b5932 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,25 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/alpine +{ + "name": "Alpine", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/base:alpine-3.22", + "features": { + "ghcr.io/devcontainer-config/features/dot-config:4": {} + } + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "uname -a", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/angular.json b/angular.json index 8b25cc2..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": { @@ -74,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": { 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..dfc5e8a 100644 --- a/proxy.conf.json +++ b/proxy.conf.json @@ -1,8 +1,18 @@ { + "/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, "changeOrigin": true, "ws": true } -} +} \ No newline at end of file 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..1a64b58 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1 +1,2 @@ + diff --git a/src/app/app.ts b/src/app/app.ts index 6aba061..d0ac9e0 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/components/login-dialog/login-dialog.component.css b/src/app/components/login-dialog/login-dialog.component.css new file mode 100644 index 0000000..31739c6 --- /dev/null +++ b/src/app/components/login-dialog/login-dialog.component.css @@ -0,0 +1,97 @@ +.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; +} + +.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..f115782 --- /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 }}
+ } + +
+ + + +
+
+
+
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/register-dialog/register-dialog.component.css b/src/app/components/register-dialog/register-dialog.component.css new file mode 100644 index 0000000..31739c6 --- /dev/null +++ b/src/app/components/register-dialog/register-dialog.component.css @@ -0,0 +1,97 @@ +.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; +} + +.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..a18b688 --- /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 }}
+ } + +
+ + + +
+
+
+
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..53c2092 --- /dev/null +++ b/src/app/components/toolbar/toolbar.component.css @@ -0,0 +1,19 @@ +.navbar { + background: var(--dlg-bg); + border: 1.5px solid var(--dlg-border); + box-shadow: var(--bb-glow); + border-radius: 4px; + padding: 0.25rem 0.75rem; +} + +.navbar-brand { + font-size: 1.5rem; + font-weight: bold; + color: var(--bb-title) !important; + font-family: 'Bebas Neue', sans-serif; + letter-spacing: 1px; +} + +.gap-2 { + gap: 0.5rem; +} diff --git a/src/app/components/toolbar/toolbar.component.html b/src/app/components/toolbar/toolbar.component.html new file mode 100644 index 0000000..9cde9b4 --- /dev/null +++ b/src/app/components/toolbar/toolbar.component.html @@ -0,0 +1,33 @@ + + +@if (showLoginDialog) { + +} + +@if (showRegisterDialog) { + +} diff --git a/src/app/components/toolbar/toolbar.component.ts b/src/app/components/toolbar/toolbar.component.ts new file mode 100644 index 0000000..63d7da6 --- /dev/null +++ b/src/app/components/toolbar/toolbar.component.ts @@ -0,0 +1,81 @@ +import { Component, DestroyRef, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +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); + + 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(); + } + + 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/welcome/welcome.component.html b/src/app/pages/welcome/welcome.component.html index 0f26090..31210d2 100644 --- a/src/app/pages/welcome/welcome.component.html +++ b/src/app/pages/welcome/welcome.component.html @@ -1,15 +1,4 @@
-
{{ modeBadge }}
- -
@@ -35,20 +24,12 @@
-
+
-
+
@@ -62,11 +43,7 @@
-
+
@@ -74,11 +51,7 @@
-
+
@@ -95,11 +68,7 @@
-
+
@@ -111,20 +80,12 @@
-
+
-
+
@@ -133,17 +94,14 @@
WELCOME
WELCOME TO
NOWCHESS
Play your next move from the skyline.
-
-
+
@@ -152,11 +110,7 @@
-
+
@@ -171,11 +125,7 @@
-
+
@@ -183,11 +133,7 @@
-
+
@@ -195,54 +141,46 @@ @if (showSpeechBubble) { -
-
-
{{ bubbleMessage }}
-
-
+
+
+
{{ bubbleMessage }}
+
+
} @if (isZoomedIn) { -
-
-
-
- Player 2 - @if (showSecondSpeechBubble) { -
-
Feed me! 🍖
-
-
- } - @if (showHappyBubble) { -
-
Happy meow! 😸
-
-
- } +
+
+
+
+ Player 2 + @if (showSecondSpeechBubble) { +
+
Feed me! 🍖
+
+ } + @if (showHappyBubble) { +
+
Happy meow! 😸
+
+
+ }
- - - @if (showMeatEmoji) { -
- 🍖 -
- }
+ + + @if (showMeatEmoji) { +
+ 🍖 +
+ }
+
}
@@ -250,83 +188,77 @@
@if (showDifficultyDialog) { -
-
-
SELECT DIFFICULTY
-
- - - -
+
+
+
SELECT DIFFICULTY
+
+ + +
+
} @if (showOptionsDialog) { -
-
-
MORE OPTIONS
-
- -
+
+
+
MORE OPTIONS
+
+
+
} @if (showJoinDialog) { -
-
-
JOIN GAME
- -
- - -
+
+
+
JOIN GAME
+ +
+ +
+
} @if (showImportDialog) { -
-
-
IMPORT GAME
-
- - -
- -
- - -
+
+
+
IMPORT GAME
+
+ + +
+ +
+ +
+
} @if (errorMessage) { -

{{ 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 f6cdb5b..a5fc29f 100644 --- a/src/app/pages/welcome/welcome.component.ts +++ b/src/app/pages/welcome/welcome.component.ts @@ -1,10 +1,15 @@ import { CommonModule } from '@angular/common'; -import { Component, OnDestroy, OnInit } 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'; @@ -32,6 +37,11 @@ interface WindowCell { styleUrls: ['./welcome.component.css'] }) 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; joiningGame = false; importing = false; @@ -48,6 +58,9 @@ export class WelcomeComponent implements OnInit, OnDestroy { 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; @@ -82,10 +95,30 @@ export class WelcomeComponent implements OnInit, OnDestroy { private readonly router: Router, private readonly gameApi: GameApiService ) { - this.initTheme(); } 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(); @@ -107,21 +140,11 @@ export class WelcomeComponent implements OnInit, OnDestroy { } } - toggleTheme(): void { - this.isSunsetMode = !this.isSunsetMode; - this.modeBadge = this.isSunsetMode ? 'SUNSET MODE' : 'NIGHT MODE'; - - if (!this.isSunsetMode) { - document.documentElement.setAttribute('data-theme', 'dark'); - localStorage.setItem('theme', 'dark'); + openDifficultyDialog(): void { + if (!this.requireAuth(() => this.showDifficultyDialog = true)) { return; } - document.documentElement.removeAttribute('data-theme'); - localStorage.removeItem('theme'); - } - - openDifficultyDialog(): void { this.closeAllDialogs(); this.showDifficultyDialog = true; } @@ -142,6 +165,10 @@ export class WelcomeComponent implements OnInit, OnDestroy { } openJoinDialog(): void { + if (!this.requireAuth(() => this.showJoinDialog = true)) { + return; + } + this.closeAllDialogs(); this.showJoinDialog = true; } @@ -156,6 +183,10 @@ export class WelcomeComponent implements OnInit, OnDestroy { } openImportDialog(): void { + if (!this.requireAuth(() => this.showImportDialog = true)) { + return; + } + this.closeAllDialogs(); this.showImportDialog = true; } @@ -176,101 +207,35 @@ export class WelcomeComponent implements OnInit, OnDestroy { } startOneVsOne(): void { - if (this.creating) { + if (!this.requireAuth(() => this.performStartOneVsOne())) { return; } - this.errorMessage = ''; - this.creating = true; - - this.gameApi - .createGame() - .pipe(finalize(() => (this.creating = false))) - .subscribe({ - next: (game) => { - void this.router.navigate(['/game', game.gameId], { - state: { theme: this.isSunsetMode ? 'light' : 'dark' } - }); - }, - error: (error) => { - this.errorMessage = getErrorMessage(error, 'Unable to create a game.'); - } - }); + this.performStartOneVsOne(); } startVsBot(difficulty: Difficulty): void { - if (this.creating) { + if (!this.requireAuth(() => this.performStartVsBot(difficulty))) { return; } - this.errorMessage = ''; - this.creating = true; - this.showDifficultyDialog = false; - - this.gameApi - .createGameVsBot(difficulty) - .pipe(finalize(() => (this.creating = false))) - .subscribe({ - next: (game) => { - 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.'); - } - }); + this.performStartVsBot(difficulty); } submitJoinGame(): void { - const gameId = this.gameIdInput.trim(); - if (this.joiningGame || !gameId) { + if (!this.requireAuth(() => this.performSubmitJoinGame())) { return; } - this.errorMessage = ''; - this.joiningGame = true; - - this.gameApi - .getGame(gameId) - .pipe(finalize(() => (this.joiningGame = false))) - .subscribe({ - next: (game) => { - 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.'); - } - }); + this.performSubmitJoinGame(); } submitImportGame(): void { - const trimmedImport = this.importText.trim(); - if (this.importing || !trimmedImport) { + if (!this.requireAuth(() => this.performSubmitImportGame())) { 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); - } - }); + this.performSubmitImportGame(); } onSpeechBubbleClick(): void { @@ -313,7 +278,6 @@ export class WelcomeComponent implements OnInit, OnDestroy { this.meatX = event.clientX - this.meatDragOffsetX; this.meatY = event.clientY - this.meatDragOffsetY; - // Get gif element position const gifElement = document.querySelector('.player-2-gif') as HTMLElement; if (!gifElement) { return; @@ -323,7 +287,6 @@ export class WelcomeComponent implements OnInit, OnDestroy { const gifCenterX = gifRect.left + gifRect.width / 2; const gifCenterY = gifRect.top + gifRect.height / 2; - // Get meat center position const meatElement = document.querySelector('.meat-emoji') as HTMLElement; if (!meatElement) { return; @@ -333,12 +296,10 @@ export class WelcomeComponent implements OnInit, OnDestroy { const meatCenterX = meatRect.left + meatRect.width / 2; const meatCenterY = meatRect.top + meatRect.height / 2; - // Calculate distance const distance = Math.sqrt( Math.pow(meatCenterX - gifCenterX, 2) + Math.pow(meatCenterY - gifCenterY, 2) ); - // If meat is close enough to gif center (within 50px), trigger the interaction if (distance < 50) { this.onMeatFed(); } @@ -355,6 +316,124 @@ export class WelcomeComponent implements OnInit, OnDestroy { 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; + } + + this.errorMessage = ''; + this.creating = true; + + this.gameApi + .createGame() + .pipe(finalize(() => (this.creating = false))) + .subscribe({ + next: (game) => { + void this.router.navigate(['/game', game.gameId], { + state: { theme: this.isSunsetMode ? 'light' : 'dark' } + }); + }, + error: (error) => { + this.errorMessage = getErrorMessage(error, 'Unable to create a game.'); + } + }); + } + + private performStartVsBot(difficulty: Difficulty): void { + if (this.creating) { + return; + } + + this.errorMessage = ''; + this.creating = true; + this.showDifficultyDialog = false; + + this.gameApi + .createGameVsBot(difficulty) + .pipe(finalize(() => (this.creating = false))) + .subscribe({ + next: (game) => { + 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.'); + } + }); + } + + private performSubmitJoinGame(): void { + const gameId = this.gameIdInput.trim(); + if (this.joiningGame || !gameId) { + return; + } + + this.errorMessage = ''; + this.joiningGame = true; + + this.gameApi + .getGame(gameId) + .pipe(finalize(() => (this.joiningGame = false))) + .subscribe({ + next: (game) => { + 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.'); + } + }); + } + + 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; @@ -363,19 +442,6 @@ export class WelcomeComponent implements OnInit, OnDestroy { this.errorMessage = ''; } - private initTheme(): void { - const savedTheme = localStorage.getItem('theme'); - this.isSunsetMode = savedTheme !== 'dark'; - this.modeBadge = this.isSunsetMode ? 'SUNSET MODE' : 'NIGHT MODE'; - - if (!this.isSunsetMode) { - document.documentElement.setAttribute('data-theme', 'dark'); - return; - } - - document.documentElement.removeAttribute('data-theme'); - } - private generateStars(count: number): void { this.stars = Array.from({ length: count }, () => { const size = Math.random() * 2 + 0.5; diff --git a/src/app/services/auth-dialog.service.ts b/src/app/services/auth-dialog.service.ts new file mode 100644 index 0000000..9d41dd1 --- /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..2083c4a --- /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/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 ac13142..4b83bf2 100644 --- a/src/styles-variables.css +++ b/src/styles-variables.css @@ -122,4 +122,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