fix: Merge branch 'main' of git.janis-eccarius.de:NowChess/NowChess-Frontend into feat/NCS-69
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
## 0.0.0 (2026-05-12)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* added bot, light and dark mode ([2de003e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2de003e497baee72f998d0d805ca1e58aababe48))
|
||||||
|
* added web view 1v1 ([1828fa3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1828fa3275ddb8ce6bb90a9f6a970ec428ebce3a))
|
||||||
|
* NCS-63 User account implementation ([#2](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/2)) ([ff75c8c](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/ff75c8ce2fad54137f04a14c15bc1d4a38fa9bb8))
|
||||||
|
* NCS-75 Frontend Deployment Dockerfile ([#4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/4)) ([bd7ec58](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bd7ec581e38b5d8e775741bf16e19b4dc67b979e))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* build issues ([36d72fd](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/36d72fd6cda41be51d28f8ac307dcdbcd31afa91))
|
||||||
|
* cleaner components seperation ([8b090e4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8b090e4d96c64c0adb253e3aefad7930108ccfb9))
|
||||||
|
* gitignore ([4da882f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/4da882f481ba7a008aac868fb37de7cb2bafea5d))
|
||||||
|
* GITIGNORE ([8df2d05](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8df2d0550ab17c9afb2d19c414eac700a75add02))
|
||||||
|
* npm ([c11c1d4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c11c1d4dce9de4bd5b463e891eebf961b37feb04))
|
||||||
|
* removed .vs ([2833ead](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2833ead7be3b47ee5c188d2d21b7326cb3cb3f26))
|
||||||
|
* removed cache files ([7ee59c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/7ee59c434bf137a08fd560bbc02ceefbcfd90f04))
|
||||||
|
* size of pieces and removed text view of the game state ([c60d00f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c60d00f9d25247504845654615065fbccd7fe448))
|
||||||
|
* structure ([3e8c7c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/3e8c7c4057e55aeec7cee8c24f6751ff24912c93))
|
||||||
+12
-6
@@ -1,19 +1,25 @@
|
|||||||
FROM node:20-alpine AS build
|
FROM --platform=$BUILDPLATFORM node:lts-alpine3.23 AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --silent
|
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
RUN npm run build -- --configuration production
|
FROM --platform=$TARGETPLATFORM nginx:stable-alpine AS production
|
||||||
|
|
||||||
FROM nginx:stable-alpine
|
RUN apk add --no-cache gettext
|
||||||
|
|
||||||
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
|
COPY --from=builder /app/dist/nowchess-frontend/browser /usr/share/nginx/html
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
COPY --from=build /app/dist/nowchess-frontend/browser /usr/share/nginx/html
|
COPY public/env.template.js /usr/share/nginx/html/env.template.js
|
||||||
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
|
|||||||
+15
-4
@@ -47,17 +47,25 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "500kB",
|
"maximumWarning": "1MB",
|
||||||
"maximumError": "4MB"
|
"maximumError": "5MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "1.5MB",
|
"maximumWarning": "1MB",
|
||||||
"maximumError": "2MB"
|
"maximumError": "5MB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
},
|
},
|
||||||
|
"staging": {
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.staging.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
{
|
{
|
||||||
@@ -83,6 +91,9 @@
|
|||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "nowchess-frontend:build:production"
|
"buildTarget": "nowchess-frontend:build:production"
|
||||||
},
|
},
|
||||||
|
"staging": {
|
||||||
|
"buildTarget": "nowchess-frontend:build:staging"
|
||||||
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"buildTarget": "nowchess-frontend:build:development"
|
"buildTarget": "nowchess-frontend:build:development"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Replace placeholders in env.template.js with environment variables and write env.js
|
||||||
|
TEMPLATE_PATH="/usr/share/nginx/html/env.template.js"
|
||||||
|
TARGET_PATH="/usr/share/nginx/html/env.js"
|
||||||
|
|
||||||
|
if [ -f "$TEMPLATE_PATH" ]; then
|
||||||
|
echo "Rendering runtime config from $TEMPLATE_PATH"
|
||||||
|
echo "Using environment variables:"
|
||||||
|
printenv
|
||||||
|
echo "----"
|
||||||
|
envsubst < "$TEMPLATE_PATH" > "$TARGET_PATH"
|
||||||
|
else
|
||||||
|
echo "No runtime template found at $TEMPLATE_PATH, skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec nginx -g 'daemon off;'
|
||||||
+18
-12
@@ -1,16 +1,22 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name localhost;
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
root /usr/share/nginx/html;
|
||||||
}
|
index index.html index.htm;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|woff2?|ttf|eot)$ {
|
location /env.js {
|
||||||
try_files $uri =404;
|
root /usr/share/nginx/html;
|
||||||
expires 1y;
|
default_type application/javascript;
|
||||||
add_header Cache-Control "public";
|
expires -1;
|
||||||
}
|
add_header 'Cache-Control' 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build --configuration production",
|
||||||
|
"build:staging": "ng build --configuration staging",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test"
|
"test": "ng test"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
window.__RUNTIME_CONFIG__ = {
|
||||||
|
API_URL: "${API_URL}",
|
||||||
|
WEBSOCKET_URL: "${WEBSOCKET_URL}"
|
||||||
|
};
|
||||||
@@ -2,12 +2,10 @@ import { Routes } from '@angular/router';
|
|||||||
import { GameComponent } from './pages/game/game.component';
|
import { GameComponent } from './pages/game/game.component';
|
||||||
import { WelcomeComponent } from './pages/welcome/welcome.component';
|
import { WelcomeComponent } from './pages/welcome/welcome.component';
|
||||||
import { ProfileComponent } from './pages/profile/profile.component';
|
import { ProfileComponent } from './pages/profile/profile.component';
|
||||||
import { ChallengesComponent } from './pages/challenges/challenges.component';
|
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', component: WelcomeComponent },
|
{ path: '', component: WelcomeComponent },
|
||||||
{ path: 'profile', component: ProfileComponent },
|
{ path: 'profile', component: ProfileComponent },
|
||||||
{ path: 'challenges', component: ChallengesComponent },
|
|
||||||
{ path: 'game/:gameId', component: GameComponent },
|
{ path: 'game/:gameId', component: GameComponent },
|
||||||
{ path: '**', redirectTo: '' }
|
{ path: '**', redirectTo: '' }
|
||||||
];
|
];
|
||||||
|
|||||||
+1
-6
@@ -2,7 +2,6 @@ import { Component, OnInit } from '@angular/core';
|
|||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import { ToolbarComponent } from './components/toolbar/toolbar.component';
|
import { ToolbarComponent } from './components/toolbar/toolbar.component';
|
||||||
import { ThemeService } from './services/theme.service';
|
import { ThemeService } from './services/theme.service';
|
||||||
import { ChallengeWebSocketService } from './services/challenge-websocket.service';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -11,13 +10,9 @@ import { ChallengeWebSocketService } from './services/challenge-websocket.servic
|
|||||||
styleUrl: './app.css'
|
styleUrl: './app.css'
|
||||||
})
|
})
|
||||||
export class App implements OnInit {
|
export class App implements OnInit {
|
||||||
constructor(
|
constructor(private readonly themeService: ThemeService) { }
|
||||||
private readonly themeService: ThemeService,
|
|
||||||
private readonly challengeWs: ChallengeWebSocketService
|
|
||||||
) { }
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.themeService.initTheme();
|
this.themeService.initTheme();
|
||||||
this.challengeWs.connect();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Load runtime configuration from window.__RUNTIME_CONFIG__
|
||||||
|
* This is injected by docker-entrypoint.sh at container startup
|
||||||
|
*/
|
||||||
|
export function loadRuntimeConfig() {
|
||||||
|
const config = (window as any).__RUNTIME_CONFIG__ || {};
|
||||||
|
return {
|
||||||
|
apiUrl: config.API_URL || '',
|
||||||
|
wsUrl: config.WEBSOCKET_URL || 'ws://localhost:8080'
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -138,6 +138,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Speech Bubble -->
|
||||||
|
@if (showSpeechBubble) {
|
||||||
|
<div class="speech-bubble-container" (click)="onSpeechBubbleClick()">
|
||||||
|
<div class="speech-bubble">
|
||||||
|
<div class="bubble-text">{{ bubbleMessage }}</div>
|
||||||
|
<div class="bubble-tail"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Speech Bubble -->
|
<!-- Speech Bubble -->
|
||||||
@if (showSpeechBubble) {
|
@if (showSpeechBubble) {
|
||||||
<div class="speech-bubble-container" (click)="onSpeechBubbleClick()">
|
<div class="speech-bubble-container" (click)="onSpeechBubbleClick()">
|
||||||
@@ -148,6 +156,39 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Zoomed Window View -->
|
||||||
|
@if (isZoomedIn) {
|
||||||
|
<div class="zoom-overlay" (click)="onZoomedViewClick()" (mousemove)="onMouseMove($event)" (mouseup)="onMouseUp()"
|
||||||
|
(mouseleave)="onMouseUp()">
|
||||||
|
<div class="zoom-window-wrapper" (click)="$event.stopPropagation()">
|
||||||
|
<div class="zoom-window-frame">
|
||||||
|
<div class="zoom-player-2">
|
||||||
|
<img src="/assets/arabian-chess/player-two.gif" alt="Player 2" class="player-2-gif"
|
||||||
|
(click)="$event.stopPropagation()" />
|
||||||
|
@if (showSecondSpeechBubble) {
|
||||||
|
<div class="second-speech-bubble">
|
||||||
|
<div class="bubble-text">Feed me! 🍖</div>
|
||||||
|
<div class="bubble-tail"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (showHappyBubble) {
|
||||||
|
<div class="happy-speech-bubble">
|
||||||
|
<div class="bubble-text">Happy meow! 😸</div>
|
||||||
|
<div class="bubble-tail"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Draggable Meat Emoji -->
|
||||||
|
@if (showMeatEmoji) {
|
||||||
|
<div class="meat-emoji" [style.left.px]="meatX" [style.top.px]="meatY" (mousedown)="onMeatMouseDown($event)">
|
||||||
|
🍖
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Zoomed Window View -->
|
<!-- Zoomed Window View -->
|
||||||
@if (isZoomedIn) {
|
@if (isZoomedIn) {
|
||||||
<div class="zoom-overlay" (click)="onZoomedViewClick()" (mousemove)="onMouseMove($event)" (mouseup)="onMouseUp()"
|
<div class="zoom-overlay" (click)="onZoomedViewClick()" (mousemove)="onMouseMove($event)" (mouseup)="onMouseUp()"
|
||||||
@@ -186,6 +227,20 @@
|
|||||||
<div class="ground"></div>
|
<div class="ground"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (showDifficultyDialog) {
|
||||||
|
<div class="dialog-overlay" (click)="closeDifficultyDialog()">
|
||||||
|
<div class="dialog-card" (click)="$event.stopPropagation()">
|
||||||
|
<div class="dialog-title">SELECT DIFFICULTY</div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" class="app-btn" (click)="startVsBot('easy')" [disabled]="creating">EASY</button>
|
||||||
|
<button type="button" class="app-btn" (click)="startVsBot('medium')" [disabled]="creating">MEDIUM</button>
|
||||||
|
<button type="button" class="app-btn" (click)="startVsBot('hard')" [disabled]="creating">HARD</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="haze"></div>
|
||||||
|
<div class="ground"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (showDifficultyDialog) {
|
@if (showDifficultyDialog) {
|
||||||
<div class="dialog-overlay" (click)="closeDifficultyDialog()">
|
<div class="dialog-overlay" (click)="closeDifficultyDialog()">
|
||||||
<div class="dialog-card" (click)="$event.stopPropagation()">
|
<div class="dialog-card" (click)="$event.stopPropagation()">
|
||||||
@@ -261,6 +316,10 @@
|
|||||||
<app-challenge-create-dialog (closeChallengeDialog)="closeChallengeDialog()"></app-challenge-create-dialog>
|
<app-challenge-create-dialog (closeChallengeDialog)="closeChallengeDialog()"></app-challenge-create-dialog>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (errorMessage) {
|
||||||
|
<p class="error-banner">{{ errorMessage }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@if (errorMessage) {
|
@if (errorMessage) {
|
||||||
<p class="error-banner">{{ errorMessage }}</p>
|
<p class="error-banner">{{ errorMessage }}</p>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, DestroyRef, OnDestroy, OnInit, inject } from '@angular/core';
|
import { Component, DestroyRef, OnDestroy, OnInit, inject } from '@angular/core';
|
||||||
|
import { Component, DestroyRef, OnDestroy, OnInit, inject } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { finalize } from 'rxjs';
|
import { finalize } from 'rxjs';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { getErrorMessage } from '../../core/http/error-message.util';
|
import { getErrorMessage } from '../../core/http/error-message.util';
|
||||||
import { CurrentUser } from '../../models/auth.models';
|
import { CurrentUser } from '../../models/auth.models';
|
||||||
import { AuthDialogService } from '../../services/auth-dialog.service';
|
import { AuthDialogService } from '../../services/auth-dialog.service';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
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 { GameApiService } from '../../services/game-api.service';
|
||||||
import { ThemeService } from '../../services/theme.service';
|
import { ThemeService } from '../../services/theme.service';
|
||||||
import { ChallengeCreateDialogComponent } from '../../components/challenge-create-dialog/challenge-create-dialog.component';
|
import { ChallengeCreateDialogComponent } from '../../components/challenge-create-dialog/challenge-create-dialog.component';
|
||||||
@@ -36,7 +41,14 @@ interface WindowCell {
|
|||||||
imports: [CommonModule, FormsModule, ChallengeCreateDialogComponent],
|
imports: [CommonModule, FormsModule, ChallengeCreateDialogComponent],
|
||||||
templateUrl: './welcome.component.html',
|
templateUrl: './welcome.component.html',
|
||||||
styleUrls: ['./welcome.component.css']
|
styleUrls: ['./welcome.component.css']
|
||||||
|
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);
|
||||||
|
|
||||||
export class WelcomeComponent implements OnInit, OnDestroy {
|
export class WelcomeComponent implements OnInit, OnDestroy {
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly authService = inject(AuthService);
|
private readonly authService = inject(AuthService);
|
||||||
@@ -49,6 +61,12 @@ export class WelcomeComponent implements OnInit, OnDestroy {
|
|||||||
showChallengeDialog = false;
|
showChallengeDialog = false;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
|
|
||||||
|
showDifficultyDialog = false;
|
||||||
|
showOptionsDialog = false;
|
||||||
|
showJoinDialog = false;
|
||||||
|
showImportDialog = false;
|
||||||
|
|
||||||
|
|
||||||
showDifficultyDialog = false;
|
showDifficultyDialog = false;
|
||||||
showOptionsDialog = false;
|
showOptionsDialog = false;
|
||||||
showJoinDialog = false;
|
showJoinDialog = false;
|
||||||
@@ -90,6 +108,43 @@ export class WelcomeComponent implements OnInit, OnDestroy {
|
|||||||
private coolColors = ['#7de8ff', '#00d5ff', '#5bc0de', '#31b0d5', '#4fc3f7', '#29b6f6'];
|
private coolColors = ['#7de8ff', '#00d5ff', '#5bc0de', '#31b0d5', '#4fc3f7', '#29b6f6'];
|
||||||
private coolGlowColors = ['#00d5ff', '#00d5ff', '#31b0d5', '#31b0d5', '#03a9f4', '#0288d1'];
|
private coolGlowColors = ['#00d5ff', '#00d5ff', '#31b0d5', '#31b0d5', '#03a9f4', '#0288d1'];
|
||||||
|
|
||||||
|
private warmColors = ['#ffe88a', '#ffcc30', '#f0ad4e', '#ec971f', '#ffb74d', '#ffa726'];
|
||||||
|
private warmGlowColors = ['#ffcc30', '#ffcc30', '#ec971f', '#ec971f', '#ff9800', '#fb8c00'];
|
||||||
|
importMode: ImportMode = 'fen';
|
||||||
|
importText = '';
|
||||||
|
|
||||||
|
isSunsetMode = false;
|
||||||
|
modeBadge = 'NIGHT MODE';
|
||||||
|
currentUser: CurrentUser | null = null;
|
||||||
|
private authDialogState: 'login' | 'register' | null = null;
|
||||||
|
private pendingAction: (() => void) | null = null;
|
||||||
|
|
||||||
|
// Speech bubble and zoom features
|
||||||
|
showSpeechBubble = false;
|
||||||
|
isZoomedIn = false;
|
||||||
|
showSecondSpeechBubble = false;
|
||||||
|
showHappyBubble = false;
|
||||||
|
showMeatEmoji = false;
|
||||||
|
bubbleMessage = 'meow';
|
||||||
|
|
||||||
|
// Meat emoji drag state
|
||||||
|
meatX = 0;
|
||||||
|
meatY = 0;
|
||||||
|
isDraggingMeat = false;
|
||||||
|
meatDragOffsetX = 0;
|
||||||
|
meatDragOffsetY = 0;
|
||||||
|
|
||||||
|
stars: Star[] = [];
|
||||||
|
bgBuildings: BackgroundBuilding[] = [];
|
||||||
|
windows: Record<string, WindowCell[]> = {};
|
||||||
|
|
||||||
|
private flickerIntervalId: ReturnType<typeof setInterval> | undefined;
|
||||||
|
private speechBubbleTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private zoomTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
private coolColors = ['#7de8ff', '#00d5ff', '#5bc0de', '#31b0d5', '#4fc3f7', '#29b6f6'];
|
||||||
|
private coolGlowColors = ['#00d5ff', '#00d5ff', '#31b0d5', '#31b0d5', '#03a9f4', '#0288d1'];
|
||||||
|
|
||||||
private warmColors = ['#ffe88a', '#ffcc30', '#f0ad4e', '#ec971f', '#ffb74d', '#ffa726'];
|
private warmColors = ['#ffe88a', '#ffcc30', '#f0ad4e', '#ec971f', '#ffb74d', '#ffa726'];
|
||||||
private warmGlowColors = ['#ffcc30', '#ffcc30', '#ec971f', '#ec971f', '#ff9800', '#fb8c00'];
|
private warmGlowColors = ['#ffcc30', '#ffcc30', '#ec971f', '#ec971f', '#ff9800', '#fb8c00'];
|
||||||
|
|
||||||
@@ -352,6 +407,136 @@ export class WelcomeComponent implements OnInit, OnDestroy {
|
|||||||
action();
|
action();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private performStartOneVsOne(): void {
|
||||||
|
if (!this.requireAuth(() => this.performStartOneVsOne())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.performStartOneVsOne();
|
||||||
|
}
|
||||||
|
|
||||||
|
startVsBot(difficulty: Difficulty): void {
|
||||||
|
if (!this.requireAuth(() => this.performStartVsBot(difficulty))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.performStartVsBot(difficulty);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitJoinGame(): void {
|
||||||
|
if (!this.requireAuth(() => this.performSubmitJoinGame())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.performSubmitJoinGame();
|
||||||
|
}
|
||||||
|
|
||||||
|
submitImportGame(): void {
|
||||||
|
if (!this.requireAuth(() => this.performSubmitImportGame())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.performSubmitImportGame();
|
||||||
|
}
|
||||||
|
|
||||||
|
onSpeechBubbleClick(): void {
|
||||||
|
this.showSpeechBubble = false;
|
||||||
|
this.isZoomedIn = true;
|
||||||
|
this.bubbleMessage = 'meow';
|
||||||
|
this.showMeatEmoji = true;
|
||||||
|
this.showHappyBubble = false;
|
||||||
|
this.showSecondSpeechBubble = true;
|
||||||
|
|
||||||
|
// Reset meat position
|
||||||
|
this.meatX = window.innerWidth / 2 - 100;
|
||||||
|
this.meatY = window.innerHeight / 2 + 150;
|
||||||
|
}
|
||||||
|
|
||||||
|
onZoomedViewClick(): void {
|
||||||
|
this.isZoomedIn = false;
|
||||||
|
this.showSecondSpeechBubble = false;
|
||||||
|
this.showHappyBubble = false;
|
||||||
|
this.showMeatEmoji = false;
|
||||||
|
this.bubbleMessage = 'meow';
|
||||||
|
|
||||||
|
if (this.zoomTimeoutId) {
|
||||||
|
clearTimeout(this.zoomTimeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMeatMouseDown(event: MouseEvent): void {
|
||||||
|
this.isDraggingMeat = true;
|
||||||
|
const rect = (event.target as HTMLElement).getBoundingClientRect();
|
||||||
|
this.meatDragOffsetX = event.clientX - rect.left;
|
||||||
|
this.meatDragOffsetY = event.clientY - rect.top;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseMove(event: MouseEvent): void {
|
||||||
|
if (!this.isDraggingMeat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.meatX = event.clientX - this.meatDragOffsetX;
|
||||||
|
this.meatY = event.clientY - this.meatDragOffsetY;
|
||||||
|
|
||||||
|
const gifElement = document.querySelector('.player-2-gif') as HTMLElement;
|
||||||
|
if (!gifElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gifRect = gifElement.getBoundingClientRect();
|
||||||
|
const gifCenterX = gifRect.left + gifRect.width / 2;
|
||||||
|
const gifCenterY = gifRect.top + gifRect.height / 2;
|
||||||
|
|
||||||
|
const meatElement = document.querySelector('.meat-emoji') as HTMLElement;
|
||||||
|
if (!meatElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meatRect = meatElement.getBoundingClientRect();
|
||||||
|
const meatCenterX = meatRect.left + meatRect.width / 2;
|
||||||
|
const meatCenterY = meatRect.top + meatRect.height / 2;
|
||||||
|
|
||||||
|
const distance = Math.sqrt(
|
||||||
|
Math.pow(meatCenterX - gifCenterX, 2) + Math.pow(meatCenterY - gifCenterY, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (distance < 50) {
|
||||||
|
this.onMeatFed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseUp(): void {
|
||||||
|
this.isDraggingMeat = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMeatFed(): void {
|
||||||
|
this.showMeatEmoji = false;
|
||||||
|
this.showSecondSpeechBubble = false;
|
||||||
|
this.showHappyBubble = true;
|
||||||
|
this.isDraggingMeat = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireAuth(action: () => void): boolean {
|
||||||
|
if (this.authService.isLoggedIn()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingAction = action;
|
||||||
|
this.authDialogService.openLogin();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private maybeRunPendingAction(): void {
|
||||||
|
if (!this.currentUser || this.authDialogState !== null || !this.pendingAction) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = this.pendingAction;
|
||||||
|
this.pendingAction = null;
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
|
||||||
private performStartOneVsOne(): void {
|
private performStartOneVsOne(): void {
|
||||||
if (this.creating) {
|
if (this.creating) {
|
||||||
return;
|
return;
|
||||||
@@ -368,6 +553,9 @@ export class WelcomeComponent implements OnInit, OnDestroy {
|
|||||||
void this.router.navigate(['/game', game.gameId], {
|
void this.router.navigate(['/game', game.gameId], {
|
||||||
state: { theme: this.isSunsetMode ? 'light' : 'dark' }
|
state: { theme: this.isSunsetMode ? 'light' : 'dark' }
|
||||||
});
|
});
|
||||||
|
void this.router.navigate(['/game', game.gameId], {
|
||||||
|
state: { theme: this.isSunsetMode ? 'light' : 'dark' }
|
||||||
|
});
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.errorMessage = getErrorMessage(error, 'Unable to create a game.');
|
this.errorMessage = getErrorMessage(error, 'Unable to create a game.');
|
||||||
@@ -375,6 +563,7 @@ export class WelcomeComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private performStartVsBot(difficulty: Difficulty): void {
|
||||||
private performStartVsBot(difficulty: Difficulty): void {
|
private performStartVsBot(difficulty: Difficulty): void {
|
||||||
if (this.creating) {
|
if (this.creating) {
|
||||||
return;
|
return;
|
||||||
@@ -383,6 +572,7 @@ export class WelcomeComponent implements OnInit, OnDestroy {
|
|||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
this.creating = true;
|
this.creating = true;
|
||||||
this.showDifficultyDialog = false;
|
this.showDifficultyDialog = false;
|
||||||
|
this.showDifficultyDialog = false;
|
||||||
|
|
||||||
this.gameApi
|
this.gameApi
|
||||||
.createGameVsBot(difficulty)
|
.createGameVsBot(difficulty)
|
||||||
@@ -392,6 +582,9 @@ export class WelcomeComponent implements OnInit, OnDestroy {
|
|||||||
void this.router.navigate(['/game', game.gameId], {
|
void this.router.navigate(['/game', game.gameId], {
|
||||||
state: { theme: this.isSunsetMode ? 'light' : 'dark' }
|
state: { theme: this.isSunsetMode ? 'light' : 'dark' }
|
||||||
});
|
});
|
||||||
|
void this.router.navigate(['/game', game.gameId], {
|
||||||
|
state: { theme: this.isSunsetMode ? 'light' : 'dark' }
|
||||||
|
});
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.errorMessage = getErrorMessage(error, 'Unable to create a game against bot.');
|
this.errorMessage = getErrorMessage(error, 'Unable to create a game against bot.');
|
||||||
@@ -399,6 +592,9 @@ export class WelcomeComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private performSubmitJoinGame(): void {
|
||||||
|
const gameId = this.gameIdInput.trim();
|
||||||
|
if (this.joiningGame || !gameId) {
|
||||||
private performSubmitJoinGame(): void {
|
private performSubmitJoinGame(): void {
|
||||||
const gameId = this.gameIdInput.trim();
|
const gameId = this.gameIdInput.trim();
|
||||||
if (this.joiningGame || !gameId) {
|
if (this.joiningGame || !gameId) {
|
||||||
@@ -409,6 +605,7 @@ export class WelcomeComponent implements OnInit, OnDestroy {
|
|||||||
this.joiningGame = true;
|
this.joiningGame = true;
|
||||||
|
|
||||||
this.gameApi
|
this.gameApi
|
||||||
|
.getGame(gameId)
|
||||||
.getGame(gameId)
|
.getGame(gameId)
|
||||||
.pipe(finalize(() => (this.joiningGame = false)))
|
.pipe(finalize(() => (this.joiningGame = false)))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
@@ -417,6 +614,10 @@ export class WelcomeComponent implements OnInit, OnDestroy {
|
|||||||
void this.router.navigate(['/game', game.gameId], {
|
void this.router.navigate(['/game', game.gameId], {
|
||||||
state: { theme: this.isSunsetMode ? 'light' : 'dark' }
|
state: { theme: this.isSunsetMode ? 'light' : 'dark' }
|
||||||
});
|
});
|
||||||
|
this.closeJoinDialog();
|
||||||
|
void this.router.navigate(['/game', game.gameId], {
|
||||||
|
state: { theme: this.isSunsetMode ? 'light' : 'dark' }
|
||||||
|
});
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.errorMessage = getErrorMessage(error, 'Unable to find or join the game.');
|
this.errorMessage = getErrorMessage(error, 'Unable to find or join the game.');
|
||||||
@@ -573,6 +774,159 @@ export class WelcomeComponent implements OnInit, OnDestroy {
|
|||||||
private stopWindowFlicker(): void {
|
private stopWindowFlicker(): void {
|
||||||
if (this.flickerIntervalId === undefined) {
|
if (this.flickerIntervalId === undefined) {
|
||||||
return;
|
return;
|
||||||
|
private generateStars(count: number): void {
|
||||||
|
this.stars = Array.from({ length: count }, () => {
|
||||||
|
const size = Math.random() * 2 + 0.5;
|
||||||
|
return {
|
||||||
|
style: {
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
left: `${Math.random() * 100}%`,
|
||||||
|
top: `${Math.random() * 62}%`,
|
||||||
|
'--d': `${(Math.random() * 3 + 1.5).toFixed(1)}s`,
|
||||||
|
'--dl': `${-(Math.random() * 6).toFixed(1)}s`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateBackgroundBuildings(): void {
|
||||||
|
const specs = [
|
||||||
|
{ l: '0%', w: '7%', h: '30vh' },
|
||||||
|
{ l: '3%', w: '4%', h: '18vh' }, // New building
|
||||||
|
{ l: '7%', w: '5%', h: '22vh' },
|
||||||
|
{ l: '11%', w: '8%', h: '28vh' },
|
||||||
|
{ l: '15%', w: '6%', h: '20vh' },
|
||||||
|
{ l: '18.5%', w: '4%', h: '18vh' },
|
||||||
|
{ l: '22.5%', w: '6%', h: '26vh' },
|
||||||
|
{ l: '28%', w: '5%', h: '25vh' },
|
||||||
|
{ l: '32%', w: '4%', h: '15vh' },
|
||||||
|
{ l: '35.5%', w: '4.5%', h: '20vh' },
|
||||||
|
{ l: '42%', w: '5%', h: '28vh' },
|
||||||
|
{ l: '47%', w: '5%', h: '22vh' }, // New building
|
||||||
|
{ l: '50%', w: '7%', h: '30vh' },
|
||||||
|
{ l: '55%', w: '6%', h: '27vh' },
|
||||||
|
{ l: '60.5%', w: '5%', h: '24vh' },
|
||||||
|
{ l: '64.5%', w: '3.5%', h: '17vh' },
|
||||||
|
{ l: '70%', w: '6%', h: '23vh' },
|
||||||
|
{ l: '75%', w: '4%', h: '19vh' },
|
||||||
|
{ l: '80.5%', w: '4%', h: '21vh' },
|
||||||
|
{ l: '85.5%', w: '9%', h: '32vh' },
|
||||||
|
{ l: '88%', w: '5%', h: '20vh' },
|
||||||
|
{ l: '91%', w: '3%', h: '16vh' }, // New building
|
||||||
|
{ l: '94%', w: '6%', h: '27vh' }
|
||||||
|
];
|
||||||
|
|
||||||
|
this.bgBuildings = specs.map((spec) => ({
|
||||||
|
style: { left: spec.l, width: spec.w, height: spec.h }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateWindowsForAllBuildings(): void {
|
||||||
|
this.windows = {
|
||||||
|
wA1: this.generateWindows(3, 4, 0.6),
|
||||||
|
wA2: this.generateWindows(4, 5, 0.55),
|
||||||
|
wA3: this.generateWindows(5, 18, 0.5),
|
||||||
|
wB1: this.generateWindows(4, 3, 0.6),
|
||||||
|
wB2: this.generateWindows(5, 20, 0.55),
|
||||||
|
wC1: this.generateWindows(5, 3, 0.7),
|
||||||
|
wC2: this.generateWindows(6, 5, 0.65),
|
||||||
|
wC3: this.generateWindows(7, 24, 0.6),
|
||||||
|
wD1: this.generateWindows(6, 3, 0.6),
|
||||||
|
wD2: this.generateWindows(6, 20, 0.5),
|
||||||
|
wE1: this.generateWindows(3, 16, 0.45)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateWindows(cols: number, rows: number, litRate: number): WindowCell[] {
|
||||||
|
const total = cols * rows;
|
||||||
|
return Array.from({ length: total }, () => this.createWindowCell(litRate));
|
||||||
|
}
|
||||||
|
|
||||||
|
private createWindowCell(litRate: number): WindowCell {
|
||||||
|
const random = Math.random();
|
||||||
|
let state: WindowCell['state'] = 'off';
|
||||||
|
let color: string | undefined;
|
||||||
|
let glowColor: string | undefined;
|
||||||
|
|
||||||
|
if (random < litRate * 0.58) { // Cool color
|
||||||
|
state = 'on';
|
||||||
|
const coolIndex = Math.floor(Math.random() * this.coolColors.length);
|
||||||
|
color = this.coolColors[coolIndex];
|
||||||
|
glowColor = this.coolGlowColors[coolIndex];
|
||||||
|
} else if (random < litRate) { // Warm color
|
||||||
|
state = 'on';
|
||||||
|
const warmIndex = Math.floor(Math.random() * this.warmColors.length);
|
||||||
|
color = this.warmColors[warmIndex];
|
||||||
|
glowColor = this.warmGlowColors[warmIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'off') {
|
||||||
|
return { state, style: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseDuration = (color && this.coolColors.includes(color)) ? 3 : 4;
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
color,
|
||||||
|
glowColor,
|
||||||
|
style: {
|
||||||
|
'background-color': color || '',
|
||||||
|
'box-shadow': glowColor ? `0 0 6px ${glowColor}, 0 0 16px ${glowColor}35` : '',
|
||||||
|
'--wd': `${(Math.random() * 4 + baseDuration).toFixed(1)}s`,
|
||||||
|
'--wdl': `${-(Math.random() * 8).toFixed(1)}s`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private startWindowFlicker(): void {
|
||||||
|
this.flickerIntervalId = setInterval(() => {
|
||||||
|
this.randomFlicker();
|
||||||
|
}, 2800);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopWindowFlicker(): void {
|
||||||
|
if (this.flickerIntervalId === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearInterval(this.flickerIntervalId);
|
||||||
|
this.flickerIntervalId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private randomFlicker(): void {
|
||||||
|
const allWindows = Object.values(this.windows).flat();
|
||||||
|
if (allWindows.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickCount = Math.floor(Math.random() * 6) + 1;
|
||||||
|
for (let i = 0; i < pickCount; i += 1) {
|
||||||
|
const target = allWindows[Math.floor(Math.random() * allWindows.length)];
|
||||||
|
if (!target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.state === 'off') {
|
||||||
|
target.state = 'on';
|
||||||
|
const isCool = Math.random() < 0.5;
|
||||||
|
const colors = isCool ? this.coolColors : this.warmColors;
|
||||||
|
const glowColors = isCool ? this.coolGlowColors : this.warmGlowColors;
|
||||||
|
const index = Math.floor(Math.random() * colors.length);
|
||||||
|
|
||||||
|
target.color = colors[index];
|
||||||
|
target.glowColor = glowColors[index];
|
||||||
|
target.style = {
|
||||||
|
'background-color': target.color || '',
|
||||||
|
'box-shadow': target.glowColor ? `0 0 6px ${target.glowColor}, 0 0 16px ${target.glowColor}35` : '',
|
||||||
|
'--wd': `${(Math.random() * 4 + (isCool ? 3 : 4)).toFixed(1)}s`,
|
||||||
|
'--wdl': `${-(Math.random() * 8).toFixed(1)}s`
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
target.state = 'off';
|
||||||
|
target.color = undefined;
|
||||||
|
target.glowColor = undefined;
|
||||||
|
target.style = {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
clearInterval(this.flickerIntervalId);
|
clearInterval(this.flickerIntervalId);
|
||||||
this.flickerIntervalId = undefined;
|
this.flickerIntervalId = undefined;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { loadRuntimeConfig } from '../app/core/config.loader';
|
||||||
|
|
||||||
|
const runtimeConfig = loadRuntimeConfig();
|
||||||
|
|
||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
apiBaseUrl: runtimeConfig.apiUrl || 'https://st.nowchess.janis-eccarius.de',
|
||||||
|
accountServiceUrl: runtimeConfig.apiUrl || 'https://st.nowchess.janis-eccarius.de',
|
||||||
|
wsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de',
|
||||||
|
apiPath: '/api/board/game'
|
||||||
|
};
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { loadRuntimeConfig } from '../app/core/config.loader';
|
||||||
|
|
||||||
|
const runtimeConfig = loadRuntimeConfig();
|
||||||
|
|
||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: false,
|
||||||
apiBaseUrl: '',
|
apiBaseUrl: runtimeConfig.apiUrl || '',
|
||||||
accountServiceUrl: '',
|
accountServiceUrl: runtimeConfig.apiUrl || '',
|
||||||
wsBaseUrl: 'ws://localhost:8080',
|
wsBaseUrl: runtimeConfig.wsUrl,
|
||||||
apiPath: '/api/board/game'
|
apiPath: '/api/board/game'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
<script src="/env.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
MAJOR=0
|
||||||
|
MINOR=1
|
||||||
|
PATCH=0
|
||||||
Reference in New Issue
Block a user