Docker, API proxy, Drone CI, .env
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.md
|
||||||
|
coverage
|
||||||
|
.angular
|
||||||
|
out-tsc
|
||||||
|
*.log
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: build-and-push
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: test
|
||||||
|
image: oven/bun:1-alpine
|
||||||
|
commands:
|
||||||
|
- bun install --frozen-lockfile
|
||||||
|
- bun run test
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
image: oven/bun:1-alpine
|
||||||
|
commands:
|
||||||
|
- bun install --frozen-lockfile
|
||||||
|
- bun run build:ssr
|
||||||
|
depends_on:
|
||||||
|
- test
|
||||||
|
|
||||||
|
- name: docker
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
repo: registry.zea.lt/miczek/tracker
|
||||||
|
registry: registry.zea.lt
|
||||||
|
username:
|
||||||
|
from_secret: registry_username
|
||||||
|
password:
|
||||||
|
from_secret: registry_password
|
||||||
|
tags:
|
||||||
|
- latest
|
||||||
|
- ${DRONE_COMMIT_SHA:0:7}
|
||||||
|
when:
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
depends_on:
|
||||||
|
- build
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Strava OAuth
|
||||||
|
STRAVA_CLIENT_ID=
|
||||||
|
STRAVA_CLIENT_SECRET=
|
||||||
|
STRAVA_REDIRECT_URI=
|
||||||
|
|
||||||
|
# Riot Games API
|
||||||
|
RIOT_API_KEY_LOL=
|
||||||
|
RIOT_API_KEY_TFT=
|
||||||
|
RIOT_REGION=
|
||||||
|
RIOT_SUMMONER_NAME_LOL=
|
||||||
|
RIOT_SUMMONER_NAME_TFT=
|
||||||
|
RIOT_TAG_LINE=
|
||||||
|
|
||||||
|
# Faceit API
|
||||||
|
FACEIT_API_KEY=
|
||||||
|
FACEIT_USER_ID=
|
||||||
|
|
||||||
|
# Tracker.gg API
|
||||||
|
TRACKER_GG_API_KEY=
|
||||||
|
ROCKET_LEAGUE_PLATFORM=
|
||||||
|
ROCKET_LEAGUE_USERNAME=
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=4000
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"ignorePatterns": ["projects/**/*"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.ts"],
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:@angular-eslint/recommended",
|
||||||
|
"plugin:@angular-eslint/template/process-inline-templates"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"@angular-eslint/directive-selector": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"type": "attribute",
|
||||||
|
"prefix": "app",
|
||||||
|
"style": "camelCase"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@angular-eslint/component-selector": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"type": "element",
|
||||||
|
"prefix": "app",
|
||||||
|
"style": "kebab-case"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
|
"max-len": ["error", { "code": 80 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["*.html"],
|
||||||
|
"extends": [
|
||||||
|
"plugin:@angular-eslint/template/recommended",
|
||||||
|
"plugin:@angular-eslint/template/accessibility"
|
||||||
|
],
|
||||||
|
"rules": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Environment and secrets
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": true,
|
||||||
|
"printWidth": 80
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# Stage 1: Build Angular and server
|
||||||
|
FROM oven/bun:1-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json bun.lock* ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
# Non-secret env for build; pass build args if needed
|
||||||
|
ARG STRAVA_CLIENT_ID=
|
||||||
|
ARG STRAVA_REDIRECT_URI=
|
||||||
|
ARG RIOT_REGION=eun1
|
||||||
|
ARG RIOT_SUMMONER_NAME_LOL=
|
||||||
|
ARG RIOT_SUMMONER_NAME_TFT=
|
||||||
|
ARG RIOT_TAG_LINE=
|
||||||
|
ARG FACEIT_USER_ID=
|
||||||
|
ARG ROCKET_LEAGUE_PLATFORM=steam
|
||||||
|
ARG ROCKET_LEAGUE_USERNAME=
|
||||||
|
|
||||||
|
ENV STRAVA_CLIENT_ID=$STRAVA_CLIENT_ID \
|
||||||
|
STRAVA_REDIRECT_URI=$STRAVA_REDIRECT_URI \
|
||||||
|
RIOT_REGION=$RIOT_REGION \
|
||||||
|
RIOT_SUMMONER_NAME_LOL=$RIOT_SUMMONER_NAME_LOL \
|
||||||
|
RIOT_SUMMONER_NAME_TFT=$RIOT_SUMMONER_NAME_TFT \
|
||||||
|
RIOT_TAG_LINE=$RIOT_TAG_LINE \
|
||||||
|
FACEIT_USER_ID=$FACEIT_USER_ID \
|
||||||
|
ROCKET_LEAGUE_PLATFORM=$ROCKET_LEAGUE_PLATFORM \
|
||||||
|
ROCKET_LEAGUE_USERNAME=$ROCKET_LEAGUE_USERNAME
|
||||||
|
|
||||||
|
RUN bun run build:ssr
|
||||||
|
|
||||||
|
# Stage 2: Runtime
|
||||||
|
FROM oven/bun:1-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/scripts ./scripts
|
||||||
|
COPY --from=builder /app/package*.json bun.lock* ./
|
||||||
|
RUN bun install --frozen-lockfile --production
|
||||||
|
|
||||||
|
EXPOSE 4000
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
CMD ["node", "scripts/serve-ssr.js"]
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 2026 Goals Tracker
|
||||||
|
|
||||||
|
An Angular Universal application to track your 2026 sport and gaming goals.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Sport Goals**: Track biking (7,500km), running (2,500km), and swimming (250km) via Strava API
|
||||||
|
- **Gaming Goals**: Monitor progress toward Diamond rank in TFT/LoL, Champion in Rocket League, and Level 10 in Faceit
|
||||||
|
- **Progress Visualization**: Charts and progress bars showing your progress over time
|
||||||
|
- **On-Track Analysis**: See if you're on track to meet your goals by the end of 2026
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Configure environment variables in `src/environments/environment.ts`:
|
||||||
|
- Strava: Client ID, Client Secret, Redirect URI
|
||||||
|
- Riot Games: API Key, Region, Summoner Names
|
||||||
|
- Faceit: API Key, User ID
|
||||||
|
- Tracker.gg: API Key, Rocket League Platform/Username
|
||||||
|
|
||||||
|
3. Run development server:
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Build for production:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Run SSR server:
|
||||||
|
```bash
|
||||||
|
npm run serve:ssr
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Setup
|
||||||
|
|
||||||
|
### Strava
|
||||||
|
1. Go to https://www.strava.com/settings/api
|
||||||
|
2. Create a new application
|
||||||
|
3. Set redirect URI to `http://localhost:4200/auth/strava/callback`
|
||||||
|
4. Copy Client ID and Client Secret
|
||||||
|
|
||||||
|
### Riot Games
|
||||||
|
1. Go to https://developer.riotgames.com/
|
||||||
|
2. Create an account and get an API key
|
||||||
|
3. Note your region (e.g., `euw1`, `na1`)
|
||||||
|
4. Provide your summoner names for LoL and TFT
|
||||||
|
|
||||||
|
### Faceit
|
||||||
|
1. Go to https://developers.faceit.com/
|
||||||
|
2. Create an API key
|
||||||
|
3. Provide your Faceit user ID
|
||||||
|
|
||||||
|
### Tracker.gg
|
||||||
|
1. Check https://tracker.gg/developers/docs for API access
|
||||||
|
2. Get API key if available
|
||||||
|
3. Provide your Rocket League platform and username
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- `src/app/core/` - Services, models, and interceptors
|
||||||
|
- `src/app/features/` - Feature components (dashboard, sport, gaming)
|
||||||
|
- `src/app/shared/` - Shared components and utilities
|
||||||
|
- `src/environments/` - Environment configuration
|
||||||
|
|
||||||
|
## Technologies
|
||||||
|
|
||||||
|
- Angular 19 with Universal SSR
|
||||||
|
- Tailwind CSS for styling
|
||||||
|
- Chart.js with ng2-charts for visualizations
|
||||||
|
- RxJS for reactive programming
|
||||||
|
- TypeScript with strict mode
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"goals-tracker": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss",
|
||||||
|
"standalone": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/goals-tracker",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": ["zone.js"],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": ["src/favicon.ico", "src/assets"],
|
||||||
|
"styles": ["src/styles.scss"],
|
||||||
|
"scripts": [],
|
||||||
|
"stylePreprocessorOptions": {
|
||||||
|
"includePaths": ["src/styles"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "500kB",
|
||||||
|
"maximumError": "1MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "6kB",
|
||||||
|
"maximumError": "10kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "goals-tracker:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "goals-tracker:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"builder": "@angular-devkit/build-angular:server",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/goals-tracker/server",
|
||||||
|
"main": "server.ts",
|
||||||
|
"tsConfig": "tsconfig.server.json"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"extractLicenses": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"prerender": {
|
||||||
|
"builder": "@angular-devkit/build-angular:prerender",
|
||||||
|
"options": {
|
||||||
|
"routes": ["/"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cli": {
|
||||||
|
"analytics": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
tracker:
|
||||||
|
image: registry.zea.lt/miczek/tracker:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'jest-preset-angular',
|
||||||
|
passWithNoTests: true,
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'],
|
||||||
|
globalSetup: 'jest-preset-angular/global-setup',
|
||||||
|
testMatch: ['**/*.spec.ts'],
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'src/**/*.ts',
|
||||||
|
'!src/**/*.spec.ts',
|
||||||
|
'!src/**/*.d.ts',
|
||||||
|
'!src/main.ts',
|
||||||
|
'!src/main.server.ts',
|
||||||
|
],
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
branches: 80,
|
||||||
|
functions: 80,
|
||||||
|
lines: 80,
|
||||||
|
statements: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "goals-tracker",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"build:prod": "bun run scripts/build-with-env.js",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "jest",
|
||||||
|
"serve:ssr": "node scripts/serve-ssr.js",
|
||||||
|
"build:ssr": "bun run build:prod && ng run goals-tracker:server:production",
|
||||||
|
"prerender": "ng run goals-tracker:prerender"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"@angular/animations": "^19.0.0",
|
||||||
|
"@angular/common": "^19.0.0",
|
||||||
|
"@angular/compiler": "^19.2.18",
|
||||||
|
"@angular/compiler-cli": "^19.2.18",
|
||||||
|
"@angular/core": "^19.2.18",
|
||||||
|
"@angular/platform-browser": "^19.0.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^19.0.0",
|
||||||
|
"@angular/platform-server": "^19.0.0",
|
||||||
|
"@angular/router": "^19.0.0",
|
||||||
|
"@angular/ssr": "^19.0.0",
|
||||||
|
"@types/three": "^0.182.0",
|
||||||
|
"chart.js": "^4.4.0",
|
||||||
|
"chartjs-plugin-datalabels": "^2.2.0",
|
||||||
|
"chartjs-plugin-zoom": "^2.2.0",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"ng2-charts": "^5.0.0",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"three": "^0.182.0",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "~0.14.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^19.2.19",
|
||||||
|
"@angular-eslint/builder": "^19.0.0",
|
||||||
|
"@angular-eslint/eslint-plugin": "^19.0.0",
|
||||||
|
"@angular-eslint/eslint-plugin-template": "^19.0.0",
|
||||||
|
"@angular-eslint/schematics": "^19.0.0",
|
||||||
|
"@angular-eslint/template-parser": "^19.0.0",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jest": "^29.5.0",
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"jest-preset-angular": "^13.1.0",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "~5.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Loads .env and runs ng build.
|
||||||
|
* Use for production build so process.env is populated for environment.prod.ts.
|
||||||
|
*/
|
||||||
|
require('dotenv').config();
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
execSync('ng build', { stdio: 'inherit' });
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Serves the SSR server by running the built main.js.
|
||||||
|
* Angular 19 server build outputs main.<hash>.js.
|
||||||
|
*/
|
||||||
|
const { readdirSync } = require('fs');
|
||||||
|
const { join } = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const serverDir = join(process.cwd(), 'dist', 'goals-tracker', 'server');
|
||||||
|
const files = readdirSync(serverDir);
|
||||||
|
const mainFile = files.find((f) => f.startsWith('main.') && f.endsWith('.js'));
|
||||||
|
|
||||||
|
if (!mainFile) {
|
||||||
|
console.error('Server build not found. Run: bun run build:ssr');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn(process.execPath, [join(serverDir, mainFile)], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env, NODE_ENV: 'production' },
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code) => process.exit(code ?? 0));
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import 'zone.js/node';
|
||||||
|
import { APP_BASE_HREF } from '@angular/common';
|
||||||
|
import { CommonEngine } from '@angular/ssr/node';
|
||||||
|
import express from 'express';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import AppServerModule from './src/main.server';
|
||||||
|
|
||||||
|
const STRAVA_API_BASE = 'https://www.strava.com/api/v3';
|
||||||
|
const FACEIT_API_BASE = 'https://open.faceit.com';
|
||||||
|
const TRACKER_API_BASE = 'https://public-api.tracker.gg';
|
||||||
|
|
||||||
|
function getRiotApiKey(path: string, gameTypeHeader: string | undefined): string {
|
||||||
|
const lolKey = process.env['RIOT_API_KEY_LOL'] || '';
|
||||||
|
const tftKey = process.env['RIOT_API_KEY_TFT'] || '';
|
||||||
|
if (gameTypeHeader === 'tft' || path.includes('/tft/')) {
|
||||||
|
return tftKey;
|
||||||
|
}
|
||||||
|
return lolKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Express app is exported so that it can be used by serverless Functions.
|
||||||
|
export function app(): express.Express {
|
||||||
|
const server = express();
|
||||||
|
const distFolder = join(process.cwd(), 'dist/goals-tracker/browser');
|
||||||
|
const indexHtml = existsSync(join(distFolder, 'index.original.html'))
|
||||||
|
? join(distFolder, 'index.original.html')
|
||||||
|
: join(distFolder, 'index.html');
|
||||||
|
|
||||||
|
const commonEngine = new CommonEngine();
|
||||||
|
|
||||||
|
server.set('view engine', 'html');
|
||||||
|
server.set('views', distFolder);
|
||||||
|
|
||||||
|
server.use(express.json());
|
||||||
|
server.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// --- API Proxy routes (before static and catch-all) ---
|
||||||
|
|
||||||
|
server.post('/api/auth/strava/token', async (req, res) => {
|
||||||
|
const { code } = req.body || {};
|
||||||
|
const clientId = process.env['STRAVA_CLIENT_ID'];
|
||||||
|
const clientSecret = process.env['STRAVA_CLIENT_SECRET'];
|
||||||
|
|
||||||
|
if (!code || !clientId || !clientSecret) {
|
||||||
|
res.status(400).json({ error: 'Missing code or Strava credentials' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
code,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
});
|
||||||
|
const response = await fetch(`${STRAVA_API_BASE}/oauth/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
res.status(response.status).json(data);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Token exchange failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.post('/api/auth/strava/refresh', async (req, res) => {
|
||||||
|
const { refresh_token } = req.body || {};
|
||||||
|
const clientId = process.env['STRAVA_CLIENT_ID'];
|
||||||
|
const clientSecret = process.env['STRAVA_CLIENT_SECRET'];
|
||||||
|
|
||||||
|
if (!refresh_token || !clientId || !clientSecret) {
|
||||||
|
res.status(400).json({ error: 'Missing refresh_token or Strava credentials' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
refresh_token,
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
});
|
||||||
|
const response = await fetch(`${STRAVA_API_BASE}/oauth/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
res.status(response.status).json(data);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Token refresh failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.all(/^\/api\/proxy\/riot\//, async (req, res) => {
|
||||||
|
const match = req.path.match(/^\/api\/proxy\/riot\/([^/]+)\/(.*)$/);
|
||||||
|
const routing = match?.[1] ?? '';
|
||||||
|
const rest = (match?.[2] ?? '').replace(/^\//, '');
|
||||||
|
const gameType = req.headers['x-riot-game-type'] as string | undefined;
|
||||||
|
const apiKey = getRiotApiKey(rest, gameType);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
res.status(502).json({ error: 'Riot API key not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = `https://${routing}.api.riotgames.com/${rest}`;
|
||||||
|
const query = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'X-Riot-Token': apiKey.trim(),
|
||||||
|
};
|
||||||
|
const fetchOpts: RequestInit = {
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
if (req.method !== 'GET' && req.body && Object.keys(req.body).length > 0) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
fetchOpts.body = JSON.stringify(req.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(targetUrl + query, fetchOpts);
|
||||||
|
const text = await response.text();
|
||||||
|
res.status(response.status);
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
res.json(JSON.parse(text || '{}'));
|
||||||
|
} else {
|
||||||
|
res.send(text);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ error: 'Riot proxy failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.all(/^\/api\/proxy\/faceit/, async (req, res) => {
|
||||||
|
const path = req.path.replace(/^\/api\/proxy\/faceit/, '') || '/';
|
||||||
|
const apiKey = process.env['FACEIT_API_KEY'];
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
res.status(502).json({ error: 'Faceit API key not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = `${FACEIT_API_BASE}${path}`;
|
||||||
|
const query = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
};
|
||||||
|
const fetchOpts: RequestInit = {
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
if (req.method !== 'GET' && req.body && Object.keys(req.body).length > 0) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
fetchOpts.body = JSON.stringify(req.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(targetUrl + query, fetchOpts);
|
||||||
|
const text = await response.text();
|
||||||
|
res.status(response.status);
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
res.json(JSON.parse(text || '{}'));
|
||||||
|
} else {
|
||||||
|
res.send(text);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ error: 'Faceit proxy failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.all(/^\/api\/proxy\/tracker/, async (req, res) => {
|
||||||
|
const path = req.path.replace(/^\/api\/proxy\/tracker/, '') || '/';
|
||||||
|
const apiKey = process.env['TRACKER_GG_API_KEY'];
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
res.status(502).json({ error: 'Tracker.gg API key not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = `${TRACKER_API_BASE}${path}`;
|
||||||
|
const query = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'TRN-Api-Key': apiKey,
|
||||||
|
};
|
||||||
|
const fetchOpts: RequestInit = {
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
if (req.method !== 'GET' && req.body && Object.keys(req.body).length > 0) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
fetchOpts.body = JSON.stringify(req.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(targetUrl + query, fetchOpts);
|
||||||
|
const text = await response.text();
|
||||||
|
res.status(response.status);
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
res.json(JSON.parse(text || '{}'));
|
||||||
|
} else {
|
||||||
|
res.send(text);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ error: 'Tracker.gg proxy failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve static files from /browser
|
||||||
|
server.get(
|
||||||
|
'*.*',
|
||||||
|
express.static(distFolder, {
|
||||||
|
maxAge: '1y',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// All regular routes use the Angular engine
|
||||||
|
server.get('*', (req, res, next) => {
|
||||||
|
const { protocol, originalUrl, baseUrl, headers } = req;
|
||||||
|
|
||||||
|
commonEngine
|
||||||
|
.render({
|
||||||
|
bootstrap: AppServerModule.bootstrap,
|
||||||
|
providers: [
|
||||||
|
...AppServerModule.providers,
|
||||||
|
{ provide: APP_BASE_HREF, useValue: baseUrl },
|
||||||
|
] as unknown as import('@angular/core').StaticProvider[],
|
||||||
|
documentFilePath: indexHtml,
|
||||||
|
url: `${protocol}://${headers.host}${originalUrl}`,
|
||||||
|
publicPath: distFolder,
|
||||||
|
})
|
||||||
|
.then((html: string) => res.send(html))
|
||||||
|
.catch((err: unknown) => next(err));
|
||||||
|
});
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(): void {
|
||||||
|
const port = process.env['PORT'] || 4000;
|
||||||
|
|
||||||
|
const server = app();
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`Node Express server listening on http://localhost:${port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const __non_webpack_require__: NodeRequire;
|
||||||
|
const mainModule = __non_webpack_require__.main;
|
||||||
|
const moduleFilename = (mainModule && mainModule.filename) || '';
|
||||||
|
if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppServerModule.bootstrap;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import 'jest-preset-angular/setup-jest';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { Component, OnInit, signal, effect, inject, computed } from '@angular/core';
|
||||||
|
import { RouterOutlet, NavigationEnd, Router } from '@angular/router';
|
||||||
|
import { ParticleBackgroundComponent } from './shared/components/particle-background/particle-background.component';
|
||||||
|
import { CustomCursorComponent } from './shared/components/custom-cursor/custom-cursor.component';
|
||||||
|
import { LoadingScreenComponent } from './shared/components/loading-screen/loading-screen.component';
|
||||||
|
import { NavbarComponent } from './shared/components/navbar/navbar.component';
|
||||||
|
import { FooterComponent } from './shared/components/footer/footer.component';
|
||||||
|
import { GoalsService } from './core/services/goals.service';
|
||||||
|
import { filter, map, startWith } from 'rxjs/operators';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
RouterOutlet,
|
||||||
|
ParticleBackgroundComponent,
|
||||||
|
CustomCursorComponent,
|
||||||
|
LoadingScreenComponent,
|
||||||
|
NavbarComponent,
|
||||||
|
FooterComponent,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<app-loading-screen
|
||||||
|
[progress]="loadingProgress"
|
||||||
|
[loadingText]="loadingText"
|
||||||
|
[fadeOut]="loadingComplete"
|
||||||
|
></app-loading-screen>
|
||||||
|
<app-custom-cursor></app-custom-cursor>
|
||||||
|
<div
|
||||||
|
class="min-h-screen bg-gradient-to-br from-gray-50 via-blue-50 to-purple-50 relative overflow-hidden transition-opacity duration-500 flex flex-col"
|
||||||
|
[class.opacity-0]="!loadingComplete()"
|
||||||
|
>
|
||||||
|
<app-particle-background></app-particle-background>
|
||||||
|
<div class="relative z-10 flex flex-col min-h-screen">
|
||||||
|
@if (showNavbarAndFooter()) {
|
||||||
|
<app-navbar></app-navbar>
|
||||||
|
}
|
||||||
|
<main [class.max-w-7xl]="showNavbarAndFooter()" [class.mx-auto]="showNavbarAndFooter()" [class.px-4]="showNavbarAndFooter()" [class.sm:px-6]="showNavbarAndFooter()" [class.lg:px-8]="showNavbarAndFooter()" [class.py-8]="showNavbarAndFooter()" class="flex-1 w-full relative z-10">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</main>
|
||||||
|
@if (showNavbarAndFooter()) {
|
||||||
|
<app-footer></app-footer>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [],
|
||||||
|
})
|
||||||
|
export class AppComponent implements OnInit {
|
||||||
|
title = 'goals-tracker';
|
||||||
|
loadingProgress = signal(0);
|
||||||
|
loadingText = signal('Initializing...');
|
||||||
|
loadingComplete = signal(false);
|
||||||
|
|
||||||
|
private readonly goalsService = inject(GoalsService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
private readonly currentUrl = toSignal(
|
||||||
|
this.router.events.pipe(
|
||||||
|
filter((event) => event instanceof NavigationEnd),
|
||||||
|
map((event) => (event as NavigationEnd).url),
|
||||||
|
startWith(this.router.url)
|
||||||
|
),
|
||||||
|
{ initialValue: this.router.url }
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly showNavbarAndFooter = computed(() => {
|
||||||
|
const url = this.currentUrl();
|
||||||
|
// Hide navbar and footer on dashboard (root path)
|
||||||
|
return url !== '/' && !url.startsWith('/?');
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Track loading progress and text from service
|
||||||
|
effect(() => {
|
||||||
|
const serviceProgress = this.goalsService.loadingProgress$();
|
||||||
|
const serviceText = this.goalsService.loadingText$();
|
||||||
|
if (serviceProgress > 0) {
|
||||||
|
// Add 5% for initial setup, service progress goes 0-95%
|
||||||
|
this.loadingProgress.set(Math.min(95, 5 + serviceProgress));
|
||||||
|
}
|
||||||
|
if (serviceText) {
|
||||||
|
this.loadingText.set(serviceText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
if (this.loadingComplete()) {
|
||||||
|
// Hide loading screen after fade-out animation
|
||||||
|
setTimeout(() => {
|
||||||
|
// Component will be hidden via CSS
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadAllData();
|
||||||
|
|
||||||
|
// Track route changes for navigation loading
|
||||||
|
this.router.events
|
||||||
|
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||||
|
.subscribe(() => {
|
||||||
|
// Data is cached, no need to reload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadAllData(): void {
|
||||||
|
this.loadingText.set('Initializing...');
|
||||||
|
this.loadingProgress.set(5);
|
||||||
|
|
||||||
|
// Small delay to show initial state
|
||||||
|
setTimeout(() => {
|
||||||
|
this.goalsService.preloadAllData().subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.loadingText.set('Complete!');
|
||||||
|
this.loadingProgress.set(100);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.loadingComplete.set(true);
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
// Even on error, show the app
|
||||||
|
this.loadingText.set('Ready');
|
||||||
|
this.loadingProgress.set(100);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.loadingComplete.set(true);
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
import { apiInterceptor } from './core/interceptors/api.interceptor';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
|
provideRouter(routes),
|
||||||
|
provideHttpClient(withInterceptors([apiInterceptor])),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/dashboard/dashboard.component').then(
|
||||||
|
(m) => m.DashboardComponent
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sport',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/sport/sport-goals.component').then(
|
||||||
|
(m) => m.SportGoalsComponent
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'gaming',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/gaming/gaming-goals.component').then(
|
||||||
|
(m) => m.GamingGoalsComponent
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'auth/strava/callback',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/sport/strava-callback.component').then(
|
||||||
|
(m) => m.StravaCallbackComponent
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { provideServerRendering } from '@angular/platform-server';
|
||||||
|
import { AppComponent } from './app.component';
|
||||||
|
import { appConfig } from './app.config';
|
||||||
|
|
||||||
|
export const AppServerModule = {
|
||||||
|
bootstrap: AppComponent,
|
||||||
|
providers: [
|
||||||
|
...appConfig.providers,
|
||||||
|
provideServerRendering(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { HttpInterceptorFn, HttpRequest } from '@angular/common/http';
|
||||||
|
|
||||||
|
let requestCount = 0;
|
||||||
|
const requestSummary: Record<string, number> = {};
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
console.log('=== API Request Summary ===');
|
||||||
|
Object.entries(requestSummary).forEach(([api, count]) => {
|
||||||
|
console.log(`${api}: ${count} requests`);
|
||||||
|
});
|
||||||
|
console.log(`Total: ${requestCount} requests`);
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('=== API Request Summary (5s) ===');
|
||||||
|
Object.entries(requestSummary).forEach(([api, count]) => {
|
||||||
|
console.log(`${api}: ${count} requests`);
|
||||||
|
});
|
||||||
|
console.log(`Total: ${requestCount} requests`);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiType(req: HttpRequest<unknown>): string {
|
||||||
|
const url = req.url;
|
||||||
|
if (url.includes('/api/auth/strava') || url.includes('strava.com')) {
|
||||||
|
return 'Strava';
|
||||||
|
}
|
||||||
|
if (url.includes('/api/proxy/riot') || url.includes('api.riotgames.com')) {
|
||||||
|
return 'Riot';
|
||||||
|
}
|
||||||
|
if (url.includes('/api/proxy/faceit') || url.includes('open.faceit.com')) {
|
||||||
|
return 'Faceit';
|
||||||
|
}
|
||||||
|
if (url.includes('/api/proxy/tracker') || url.includes('tracker.gg')) {
|
||||||
|
return 'Tracker.gg';
|
||||||
|
}
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function logRequest(req: HttpRequest<unknown>, apiType: string): void {
|
||||||
|
requestCount++;
|
||||||
|
requestSummary[apiType] = (requestSummary[apiType] || 0) + 1;
|
||||||
|
console.log(
|
||||||
|
`[API Request #${requestCount}] ${apiType} | ${req.method} ${req.url}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API interceptor - pass through all requests (auth is handled by backend proxy).
|
||||||
|
* Optionally logs request summary.
|
||||||
|
*/
|
||||||
|
export const apiInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
const apiType = getApiType(req);
|
||||||
|
logRequest(req, apiType);
|
||||||
|
return next(req);
|
||||||
|
};
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
// Strava API Responses
|
||||||
|
export interface StravaActivity {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
distance: number; // meters
|
||||||
|
start_date: string;
|
||||||
|
moving_time: number; // seconds
|
||||||
|
sport_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StravaTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
expires_at: number;
|
||||||
|
athlete: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Riot API Responses
|
||||||
|
export interface RiotAccount {
|
||||||
|
puuid: string;
|
||||||
|
gameName: string;
|
||||||
|
tagLine: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiotSummoner {
|
||||||
|
id: string;
|
||||||
|
accountId: string;
|
||||||
|
puuid: string;
|
||||||
|
name: string;
|
||||||
|
profileIconId: number;
|
||||||
|
revisionDate: number;
|
||||||
|
summonerLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiotLeagueEntry {
|
||||||
|
leagueId: string;
|
||||||
|
summonerId: string;
|
||||||
|
summonerName: string;
|
||||||
|
queueType: string;
|
||||||
|
tier: string;
|
||||||
|
rank: string;
|
||||||
|
leaguePoints: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
veteran: boolean;
|
||||||
|
inactive: boolean;
|
||||||
|
freshBlood: boolean;
|
||||||
|
hotStreak: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiotMatch {
|
||||||
|
metadata: {
|
||||||
|
matchId: string;
|
||||||
|
participants: string[];
|
||||||
|
};
|
||||||
|
info: {
|
||||||
|
gameCreation: number;
|
||||||
|
gameDuration: number;
|
||||||
|
gameEndTimestamp: number;
|
||||||
|
participants: Array<{
|
||||||
|
puuid: string;
|
||||||
|
teamId: number;
|
||||||
|
win: boolean;
|
||||||
|
championName: string;
|
||||||
|
kills: number;
|
||||||
|
deaths: number;
|
||||||
|
assists: number;
|
||||||
|
}>;
|
||||||
|
teams: Array<{
|
||||||
|
teamId: number;
|
||||||
|
win: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TFTMatch {
|
||||||
|
metadata: {
|
||||||
|
data_version: string;
|
||||||
|
match_id: string;
|
||||||
|
participants: string[];
|
||||||
|
};
|
||||||
|
info: {
|
||||||
|
game_datetime: number;
|
||||||
|
game_length: number;
|
||||||
|
game_version: string;
|
||||||
|
participants: Array<{
|
||||||
|
puuid: string;
|
||||||
|
placement: number;
|
||||||
|
level: number;
|
||||||
|
gold_left: number;
|
||||||
|
last_round: number;
|
||||||
|
time_eliminated: number;
|
||||||
|
total_damage_to_players: number;
|
||||||
|
traits: Array<{
|
||||||
|
name: string;
|
||||||
|
num_units: number;
|
||||||
|
style: number;
|
||||||
|
tier_current: number;
|
||||||
|
tier_total: number;
|
||||||
|
}>;
|
||||||
|
units: Array<{
|
||||||
|
character_id: string;
|
||||||
|
tier: number;
|
||||||
|
items: number[];
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
queue_id: number;
|
||||||
|
tft_game_type: string;
|
||||||
|
tft_set_core_name: string;
|
||||||
|
tft_set_number: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Faceit API Responses
|
||||||
|
export interface FaceitPlayerSearch {
|
||||||
|
items: Array<{
|
||||||
|
player_id: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
country: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FaceitPlayer {
|
||||||
|
player_id: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
country: string;
|
||||||
|
cover_image: string;
|
||||||
|
cover_featured_image: string;
|
||||||
|
infractions: unknown;
|
||||||
|
verified: boolean;
|
||||||
|
faceit_url: string;
|
||||||
|
membership_type: string;
|
||||||
|
membership_subscriptions: unknown[];
|
||||||
|
games: {
|
||||||
|
[key: string]: {
|
||||||
|
game_profile_id: string;
|
||||||
|
region: string;
|
||||||
|
regions: string[];
|
||||||
|
skill_level: number;
|
||||||
|
faceit_elo: number;
|
||||||
|
game_player_id: string;
|
||||||
|
game_player_name: string;
|
||||||
|
skill_level_label: string;
|
||||||
|
regions_object: unknown[];
|
||||||
|
game_regions: unknown[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
friends_ids: string[];
|
||||||
|
bans: unknown[];
|
||||||
|
new_steam_id: string;
|
||||||
|
steam_id_64: string;
|
||||||
|
steam_nickname: string;
|
||||||
|
memberships: string[];
|
||||||
|
faceit_elo: number;
|
||||||
|
created_at: number;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FaceitMatch {
|
||||||
|
match_id: string;
|
||||||
|
game_id: string;
|
||||||
|
region: string;
|
||||||
|
match_type: string;
|
||||||
|
game_mode: string;
|
||||||
|
max_players: number;
|
||||||
|
teams_size: number;
|
||||||
|
teams: {
|
||||||
|
faction1: {
|
||||||
|
team_id: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
type: string;
|
||||||
|
players: Array<{
|
||||||
|
player_id: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
skill_level: number;
|
||||||
|
game_player_id: string;
|
||||||
|
game_player_name: string;
|
||||||
|
faceit_elo: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
faction2: {
|
||||||
|
team_id: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
type: string;
|
||||||
|
players: Array<{
|
||||||
|
player_id: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
skill_level: number;
|
||||||
|
game_player_id: string;
|
||||||
|
game_player_name: string;
|
||||||
|
faceit_elo: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
playing_players: string[];
|
||||||
|
competition_id: string;
|
||||||
|
competition_name: string;
|
||||||
|
competition_type: string;
|
||||||
|
organizer_id: string;
|
||||||
|
status: string;
|
||||||
|
started_at: number;
|
||||||
|
finished_at: number;
|
||||||
|
results: {
|
||||||
|
winner: string;
|
||||||
|
score: {
|
||||||
|
faction1: number;
|
||||||
|
faction2: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FaceitMatchStats {
|
||||||
|
rounds: Array<{
|
||||||
|
best_of: string;
|
||||||
|
competition_id: string;
|
||||||
|
game_id: string;
|
||||||
|
game_mode: string;
|
||||||
|
match_id: string;
|
||||||
|
match_round: string;
|
||||||
|
played: string;
|
||||||
|
round_stats: Record<string, string>;
|
||||||
|
teams: Array<{
|
||||||
|
team_id: string;
|
||||||
|
premade: boolean;
|
||||||
|
team_stats: Record<string, string>;
|
||||||
|
players: Array<{
|
||||||
|
player_id: string;
|
||||||
|
nickname: string;
|
||||||
|
player_stats: Record<string, string>;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracker.gg API Responses
|
||||||
|
export interface TrackerGGProfile {
|
||||||
|
data: {
|
||||||
|
platformInfo: {
|
||||||
|
platformSlug: string;
|
||||||
|
platformUserId: string;
|
||||||
|
platformUserHandle: string;
|
||||||
|
platformUserIdentifier: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
additionalParameters: unknown;
|
||||||
|
};
|
||||||
|
userInfo: {
|
||||||
|
userId: string;
|
||||||
|
isPremium: boolean;
|
||||||
|
isVerified: boolean;
|
||||||
|
isInfluencer: boolean;
|
||||||
|
isPartner: boolean;
|
||||||
|
countryCode: string;
|
||||||
|
customAvatarUrl: string;
|
||||||
|
customHeroUrl: string;
|
||||||
|
socialAccounts: unknown[];
|
||||||
|
pageviews: number;
|
||||||
|
isSuspicious: boolean;
|
||||||
|
};
|
||||||
|
metadata: {
|
||||||
|
lastUpdated: {
|
||||||
|
value: string;
|
||||||
|
displayValue: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
segments: Array<{
|
||||||
|
type: string;
|
||||||
|
attributes: {
|
||||||
|
playlistId: string;
|
||||||
|
playlistName: string;
|
||||||
|
rank: {
|
||||||
|
metadata: {
|
||||||
|
iconUrl: string;
|
||||||
|
rankName: string;
|
||||||
|
tierName: string;
|
||||||
|
tier: number;
|
||||||
|
};
|
||||||
|
value: number;
|
||||||
|
displayValue: string;
|
||||||
|
};
|
||||||
|
rating: {
|
||||||
|
value: number;
|
||||||
|
displayValue: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
metadata: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
expiryDate: string;
|
||||||
|
stats: unknown;
|
||||||
|
}>;
|
||||||
|
availableSegments: unknown[];
|
||||||
|
expiryDate: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
export type GameType = 'tft' | 'lol' | 'rocket-league' | 'faceit';
|
||||||
|
|
||||||
|
export interface Match {
|
||||||
|
id: string;
|
||||||
|
game: GameType;
|
||||||
|
date: Date;
|
||||||
|
result: 'win' | 'loss' | 'draw';
|
||||||
|
rank?: string;
|
||||||
|
lp?: number;
|
||||||
|
lpChange?: number;
|
||||||
|
score?: string;
|
||||||
|
opponent?: string;
|
||||||
|
champion?: string;
|
||||||
|
kda?: string;
|
||||||
|
placement?: number; // For TFT (1-8)
|
||||||
|
duration?: number; // Game duration in seconds
|
||||||
|
map?: string;
|
||||||
|
matchUrl?: string; // Link to match details on external site
|
||||||
|
isPromotion?: boolean; // Ranked up after this match
|
||||||
|
isDemotion?: boolean; // Ranked down after this match
|
||||||
|
newRank?: string; // The new rank after promotion/demotion
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RankInfo {
|
||||||
|
tier: string;
|
||||||
|
rank?: string;
|
||||||
|
leaguePoints: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
hotStreak?: boolean;
|
||||||
|
veteran?: boolean;
|
||||||
|
freshBlood?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Streak {
|
||||||
|
type: 'win' | 'loss' | 'none';
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamingStats {
|
||||||
|
winRate: number;
|
||||||
|
totalGames: number;
|
||||||
|
recentWins: number;
|
||||||
|
recentLosses: number;
|
||||||
|
streak: Streak;
|
||||||
|
elo?: number;
|
||||||
|
peakRank?: string;
|
||||||
|
avgPlacement?: number; // For TFT
|
||||||
|
kda?: number; // KDA ratio (for Faceit)
|
||||||
|
adr?: number; // Average Damage per Round (for Faceit)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamingGoal {
|
||||||
|
game: GameType;
|
||||||
|
target: string; // 'Diamond', 'Champion', 'Level 10'
|
||||||
|
current: string;
|
||||||
|
progress: number; // 0-100
|
||||||
|
recentMatches: Match[];
|
||||||
|
rankInfo?: RankInfo;
|
||||||
|
stats?: GamingStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamingProgress {
|
||||||
|
tft: GamingGoal;
|
||||||
|
lol: GamingGoal;
|
||||||
|
rocketLeague: GamingGoal;
|
||||||
|
faceit: GamingGoal;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export type SportType = 'bike' | 'run' | 'swim';
|
||||||
|
|
||||||
|
export interface Activity {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: SportType;
|
||||||
|
distance: number; // meters
|
||||||
|
startDate: Date;
|
||||||
|
movingTime: number; // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SportGoal {
|
||||||
|
type: SportType;
|
||||||
|
target: number; // km
|
||||||
|
current: number; // km
|
||||||
|
percentage: number;
|
||||||
|
activities: Activity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SportProgress {
|
||||||
|
bike: SportGoal;
|
||||||
|
run: SportGoal;
|
||||||
|
swim: SportGoal;
|
||||||
|
overallPercentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable, of, forkJoin, from } from 'rxjs';
|
||||||
|
import { map, catchError, switchMap, concatMap, delay, toArray } from 'rxjs/operators';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import {
|
||||||
|
GamingGoal,
|
||||||
|
GamingStats,
|
||||||
|
Match,
|
||||||
|
Streak,
|
||||||
|
} from '../models/gaming-goal.model';
|
||||||
|
import {
|
||||||
|
FaceitPlayer,
|
||||||
|
FaceitMatch,
|
||||||
|
FaceitMatchStats,
|
||||||
|
FaceitPlayerSearch,
|
||||||
|
} from '../models/api-response.model';
|
||||||
|
|
||||||
|
const FACEIT_API_BASE = '/api/proxy/faceit/data/v4';
|
||||||
|
const FACEIT_GAME_ID = 'cs2'; // CS2 game ID
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class FaceitService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly userId = environment.faceit.userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Faceit playerId (for Load More functionality)
|
||||||
|
*/
|
||||||
|
getFaceitPlayerId(): Observable<string | null> {
|
||||||
|
if (!this.userId) {
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
return this.searchPlayer(this.userId).pipe(
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Faceit level and progress with full stats
|
||||||
|
*/
|
||||||
|
getFaceitProgress(): Observable<GamingGoal> {
|
||||||
|
if (!this.userId) {
|
||||||
|
return of(this.getEmptyGoal());
|
||||||
|
}
|
||||||
|
|
||||||
|
// First search for player by nickname to get player_id
|
||||||
|
return this.searchPlayer(this.userId).pipe(
|
||||||
|
switchMap((playerId) => {
|
||||||
|
if (!playerId) {
|
||||||
|
console.warn('Faceit player not found. Returning empty goal.');
|
||||||
|
return of(this.getEmptyGoal());
|
||||||
|
}
|
||||||
|
// Fetch player info and match history in parallel
|
||||||
|
return forkJoin({
|
||||||
|
player: this.getPlayerById(playerId),
|
||||||
|
matches: this.getPlayerMatches(playerId),
|
||||||
|
stats: this.getPlayerStats(playerId),
|
||||||
|
}).pipe(
|
||||||
|
map(({ player, matches, stats }) => {
|
||||||
|
const gameData = player.games[FACEIT_GAME_ID];
|
||||||
|
const level = gameData?.skill_level || 0;
|
||||||
|
const currentElo = gameData?.faceit_elo || 0;
|
||||||
|
const current = `Level ${level}`;
|
||||||
|
// Calculate progress based on ELO (Level 10 = 2001+ ELO)
|
||||||
|
const progress = this.calculateEloProgress(currentElo, 2001);
|
||||||
|
|
||||||
|
// Calculate rank changes for each match based on ELO
|
||||||
|
const matchesWithRankChanges = this.calculateMatchRankChanges(
|
||||||
|
matches,
|
||||||
|
currentElo
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate streak from recent matches
|
||||||
|
const streak = this.calculateStreak(matchesWithRankChanges);
|
||||||
|
const recentWins = matchesWithRankChanges.filter(
|
||||||
|
(m) => m.result === 'win'
|
||||||
|
).length;
|
||||||
|
const recentLosses = matchesWithRankChanges.filter(
|
||||||
|
(m) => m.result === 'loss'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const gamingStats: GamingStats = {
|
||||||
|
winRate: stats?.winRate ?? 0,
|
||||||
|
totalGames: stats?.totalGames ?? 0,
|
||||||
|
recentWins,
|
||||||
|
recentLosses,
|
||||||
|
streak,
|
||||||
|
elo: currentElo,
|
||||||
|
kda: stats?.kda,
|
||||||
|
adr: stats?.adr,
|
||||||
|
};
|
||||||
|
|
||||||
|
const goal: GamingGoal = {
|
||||||
|
game: 'faceit',
|
||||||
|
target: 'Level 10',
|
||||||
|
current,
|
||||||
|
progress,
|
||||||
|
recentMatches: matchesWithRankChanges,
|
||||||
|
stats: gamingStats,
|
||||||
|
};
|
||||||
|
return goal;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Error fetching Faceit progress:', error);
|
||||||
|
if (error.status === 404) {
|
||||||
|
console.warn(
|
||||||
|
'Faceit player not found. Verify your Faceit username. ' +
|
||||||
|
'Find it in your profile URL: https://www.faceit.com/en/players/YOUR_USERNAME'
|
||||||
|
);
|
||||||
|
} else if (error.status === 401 || error.status === 403) {
|
||||||
|
console.warn(
|
||||||
|
'Faceit API authentication failed. Verify your API key at: ' +
|
||||||
|
'https://developers.faceit.com/'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return of(this.getEmptyGoal());
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent matches (public method)
|
||||||
|
*/
|
||||||
|
getRecentMatches(): Observable<Match[]> {
|
||||||
|
if (!this.userId) {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.searchPlayer(this.userId).pipe(
|
||||||
|
switchMap((playerId) => {
|
||||||
|
if (!playerId) {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
return this.getPlayerMatches(playerId);
|
||||||
|
}),
|
||||||
|
catchError(() => of([]))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get player's match history by player ID with ELO changes
|
||||||
|
*/
|
||||||
|
private getPlayerMatches(playerId: string): Observable<Match[]> {
|
||||||
|
return this.http
|
||||||
|
.get<{ items: FaceitMatch[] }>(
|
||||||
|
`${FACEIT_API_BASE}/players/${playerId}/history`,
|
||||||
|
{ params: { game: FACEIT_GAME_ID, limit: '5' } }
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
switchMap((response) => {
|
||||||
|
const matches = response.items || [];
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch match stats in batches of 5 to avoid rate limiting
|
||||||
|
const batchSize = 5;
|
||||||
|
const batches: typeof matches[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < matches.length; i += batchSize) {
|
||||||
|
batches.push(matches.slice(i, i + batchSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process batches sequentially with delay between batches
|
||||||
|
return from(batches).pipe(
|
||||||
|
concatMap((batch, index) => {
|
||||||
|
const batchRequests = batch.map((match) =>
|
||||||
|
this.getMatchStats(match.match_id, playerId).pipe(
|
||||||
|
map((eloChange) => this.mapToMatch(match, playerId, eloChange)),
|
||||||
|
catchError(() => of(this.mapToMatch(match, playerId, null)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return forkJoin(batchRequests).pipe(
|
||||||
|
// Add delay between batches (except first one)
|
||||||
|
delay(index === 0 ? 0 : 200)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
// Collect all batches into a single array
|
||||||
|
toArray(),
|
||||||
|
map((allBatches) => {
|
||||||
|
// Flatten array of arrays
|
||||||
|
return allBatches.flat();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
catchError(() => of([]))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get more Faceit matches (for Load More functionality)
|
||||||
|
*/
|
||||||
|
getMoreFaceitMatches(playerId: string, startIndex: number, count: number = 5): Observable<Match[]> {
|
||||||
|
if (!playerId) {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
return this.http
|
||||||
|
.get<{ items: FaceitMatch[] }>(
|
||||||
|
`${FACEIT_API_BASE}/players/${playerId}/history`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
game: FACEIT_GAME_ID,
|
||||||
|
limit: count.toString(),
|
||||||
|
offset: startIndex.toString(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
switchMap((response) => {
|
||||||
|
const matches = response.items || [];
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch match stats in batches of 5 to avoid rate limiting
|
||||||
|
const batchSize = 5;
|
||||||
|
const batches: typeof matches[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < matches.length; i += batchSize) {
|
||||||
|
batches.push(matches.slice(i, i + batchSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process batches sequentially with delay between batches
|
||||||
|
return from(batches).pipe(
|
||||||
|
concatMap((batch, index) => {
|
||||||
|
const batchRequests = batch.map((match) =>
|
||||||
|
this.getMatchStats(match.match_id, playerId).pipe(
|
||||||
|
map((eloChange) => this.mapToMatch(match, playerId, eloChange)),
|
||||||
|
catchError(() => of(this.mapToMatch(match, playerId, null)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return forkJoin(batchRequests).pipe(
|
||||||
|
// Add delay between batches (except first one)
|
||||||
|
delay(index === 0 ? 0 : 200)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
// Collect all batches into a single array
|
||||||
|
toArray(),
|
||||||
|
map((allBatches) => {
|
||||||
|
// Flatten array of arrays
|
||||||
|
return allBatches.flat();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Error fetching more Faceit matches:', error);
|
||||||
|
return of([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get match stats to extract ELO change for player
|
||||||
|
*/
|
||||||
|
private getMatchStats(
|
||||||
|
matchId: string,
|
||||||
|
playerId: string
|
||||||
|
): Observable<number | null> {
|
||||||
|
return this.http
|
||||||
|
.get<FaceitMatchStats>(`${FACEIT_API_BASE}/matches/${matchId}/stats`)
|
||||||
|
.pipe(
|
||||||
|
map((stats) => {
|
||||||
|
// Find player in the stats to get ELO change
|
||||||
|
for (const round of stats.rounds || []) {
|
||||||
|
for (const team of round.teams || []) {
|
||||||
|
const player = team.players?.find(
|
||||||
|
(p) => p.player_id === playerId
|
||||||
|
);
|
||||||
|
if (player?.player_stats?.['Elo']) {
|
||||||
|
// Some stats show current ELO, not change
|
||||||
|
// Try to find elo_change if available
|
||||||
|
const eloChange = player.player_stats?.['Elo Change'];
|
||||||
|
if (eloChange) {
|
||||||
|
return parseInt(eloChange, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get player's stats
|
||||||
|
*/
|
||||||
|
private getPlayerStats(
|
||||||
|
playerId: string
|
||||||
|
): Observable<{ winRate: number; totalGames: number; kda: number; adr: number } | null> {
|
||||||
|
return this.http
|
||||||
|
.get<{
|
||||||
|
lifetime: {
|
||||||
|
Matches: string;
|
||||||
|
'Win Rate %': string;
|
||||||
|
Wins: string;
|
||||||
|
'Current Win Streak': string;
|
||||||
|
'Longest Win Streak': string;
|
||||||
|
'Average K/D Ratio': string;
|
||||||
|
'Average Damage per Round': string;
|
||||||
|
};
|
||||||
|
}>(`${FACEIT_API_BASE}/players/${playerId}/stats/${FACEIT_GAME_ID}`)
|
||||||
|
.pipe(
|
||||||
|
map((response) => {
|
||||||
|
const lifetime = response.lifetime;
|
||||||
|
return {
|
||||||
|
winRate: parseFloat(lifetime['Win Rate %']) || 0,
|
||||||
|
totalGames: parseInt(lifetime.Matches, 10) || 0,
|
||||||
|
kda: parseFloat(lifetime['Average K/D Ratio']) || 0,
|
||||||
|
adr: parseFloat(lifetime['Average Damage per Round']) || 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate current streak from matches
|
||||||
|
*/
|
||||||
|
private calculateStreak(matches: Match[]): Streak {
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return { type: 'none', count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstResult = matches[0].result;
|
||||||
|
if (firstResult === 'draw') {
|
||||||
|
return { type: 'none', count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const match of matches) {
|
||||||
|
if (match.result === firstResult) {
|
||||||
|
count++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: firstResult, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for player by nickname to get player_id
|
||||||
|
*/
|
||||||
|
private searchPlayer(nickname: string): Observable<string | null> {
|
||||||
|
return this.http
|
||||||
|
.get<FaceitPlayerSearch>(`${FACEIT_API_BASE}/search/players`, {
|
||||||
|
params: {
|
||||||
|
nickname: nickname,
|
||||||
|
game: FACEIT_GAME_ID,
|
||||||
|
limit: '1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
map((response) => {
|
||||||
|
if (response.items && response.items.length > 0) {
|
||||||
|
// Find exact match (case-insensitive)
|
||||||
|
const exactMatch = response.items.find(
|
||||||
|
(item) =>
|
||||||
|
item.nickname.toLowerCase() === nickname.toLowerCase()
|
||||||
|
);
|
||||||
|
return exactMatch?.player_id || response.items[0].player_id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Error searching for Faceit player:', error);
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get player by player_id
|
||||||
|
*/
|
||||||
|
private getPlayerById(playerId: string): Observable<FaceitPlayer> {
|
||||||
|
return this.http.get<FaceitPlayer>(
|
||||||
|
`${FACEIT_API_BASE}/players/${playerId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToMatch(
|
||||||
|
match: FaceitMatch,
|
||||||
|
playerId: string,
|
||||||
|
eloChange: number | null = null
|
||||||
|
): Match {
|
||||||
|
// Determine which faction the player is on
|
||||||
|
let result: 'win' | 'loss' | 'draw' = 'draw';
|
||||||
|
let playerFaction: 'faction1' | 'faction2' | null = null;
|
||||||
|
|
||||||
|
if (match.teams?.faction1?.players?.some((p) => p.player_id === playerId)) {
|
||||||
|
playerFaction = 'faction1';
|
||||||
|
} else if (
|
||||||
|
match.teams?.faction2?.players?.some((p) => p.player_id === playerId)
|
||||||
|
) {
|
||||||
|
playerFaction = 'faction2';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerFaction && match.results?.winner) {
|
||||||
|
result = match.results.winner === playerFaction ? 'win' : 'loss';
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = match.results?.score
|
||||||
|
? `${match.results.score.faction1}-${match.results.score.faction2}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Faceit has direct match room URLs
|
||||||
|
const matchUrl = `https://www.faceit.com/en/cs2/room/${match.match_id}`;
|
||||||
|
|
||||||
|
// Only include ELO change if we have real data from the API
|
||||||
|
return {
|
||||||
|
id: match.match_id,
|
||||||
|
game: 'faceit',
|
||||||
|
date: new Date(match.started_at * 1000),
|
||||||
|
result,
|
||||||
|
score,
|
||||||
|
map: match.game_mode,
|
||||||
|
duration: match.finished_at
|
||||||
|
? match.finished_at - match.started_at
|
||||||
|
: undefined,
|
||||||
|
matchUrl,
|
||||||
|
lpChange: eloChange ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Faceit ELO thresholds for each level
|
||||||
|
* Source: https://support.faceit.com/hc/en-us/articles/208511105-Skill-Level-and-ELO
|
||||||
|
*/
|
||||||
|
private readonly eloThresholds: number[] = [
|
||||||
|
0, // Level 1: 1-500
|
||||||
|
501, // Level 2: 501-750
|
||||||
|
751, // Level 3: 751-900
|
||||||
|
901, // Level 4: 901-1050
|
||||||
|
1051, // Level 5: 1051-1200
|
||||||
|
1201, // Level 6: 1201-1350
|
||||||
|
1351, // Level 7: 1351-1530
|
||||||
|
1531, // Level 8: 1531-1750
|
||||||
|
1751, // Level 9: 1751-2000
|
||||||
|
2001, // Level 10: 2001+
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Faceit level from ELO
|
||||||
|
*/
|
||||||
|
private getLevelFromElo(elo: number): number {
|
||||||
|
for (let i = this.eloThresholds.length - 1; i >= 0; i--) {
|
||||||
|
if (elo >= this.eloThresholds[i]) {
|
||||||
|
return i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if ELO change resulted in promotion/demotion
|
||||||
|
*/
|
||||||
|
private checkRankChange(
|
||||||
|
currentElo: number,
|
||||||
|
eloChange: number
|
||||||
|
): { isPromotion: boolean; isDemotion: boolean; newRank?: string } {
|
||||||
|
const previousElo = currentElo - eloChange;
|
||||||
|
const currentLevel = this.getLevelFromElo(currentElo);
|
||||||
|
const previousLevel = this.getLevelFromElo(previousElo);
|
||||||
|
|
||||||
|
if (currentLevel > previousLevel) {
|
||||||
|
return {
|
||||||
|
isPromotion: true,
|
||||||
|
isDemotion: false,
|
||||||
|
newRank: `Level ${currentLevel}`,
|
||||||
|
};
|
||||||
|
} else if (currentLevel < previousLevel) {
|
||||||
|
return {
|
||||||
|
isPromotion: false,
|
||||||
|
isDemotion: true,
|
||||||
|
newRank: `Level ${currentLevel}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isPromotion: false, isDemotion: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate rank changes for each match by working backwards from current ELO
|
||||||
|
* Matches are ordered most recent first
|
||||||
|
*/
|
||||||
|
private calculateMatchRankChanges(
|
||||||
|
matches: Match[],
|
||||||
|
currentElo: number
|
||||||
|
): Match[] {
|
||||||
|
let runningElo = currentElo;
|
||||||
|
|
||||||
|
// Process matches from most recent to oldest
|
||||||
|
return matches.map((match) => {
|
||||||
|
const eloChange = match.lpChange ?? 0;
|
||||||
|
const eloAfterMatch = runningElo;
|
||||||
|
|
||||||
|
// Check if this match caused a rank change
|
||||||
|
const rankChange = this.checkRankChange(eloAfterMatch, eloChange);
|
||||||
|
|
||||||
|
// Update running ELO for next iteration (going backwards in time)
|
||||||
|
runningElo = eloAfterMatch - eloChange;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...match,
|
||||||
|
isPromotion: rankChange.isPromotion,
|
||||||
|
isDemotion: rankChange.isDemotion,
|
||||||
|
newRank: rankChange.newRank,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateLevelProgress(current: number, target: number): number {
|
||||||
|
if (current >= target) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
return (current / target) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate progress based on ELO (more precise than level-based)
|
||||||
|
* Target Level 10 = 2001 ELO
|
||||||
|
*/
|
||||||
|
private calculateEloProgress(currentElo: number, targetElo: number): number {
|
||||||
|
if (currentElo >= targetElo) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
// Calculate progress: (current ELO / target ELO) * 100
|
||||||
|
// This gives more granular progress than just level-based
|
||||||
|
return Math.min(100, (currentElo / targetElo) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEmptyGoal(): GamingGoal {
|
||||||
|
return {
|
||||||
|
game: 'faceit',
|
||||||
|
target: 'Level 10',
|
||||||
|
current: 'Level 0',
|
||||||
|
progress: 0,
|
||||||
|
recentMatches: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { inject, Injectable, signal, computed } from '@angular/core';
|
||||||
|
import { Observable, combineLatest, of, forkJoin } from 'rxjs';
|
||||||
|
import { map, catchError, shareReplay } from 'rxjs/operators';
|
||||||
|
import { StravaService } from './strava.service';
|
||||||
|
import { RiotService } from './riot.service';
|
||||||
|
import { FaceitService } from './faceit.service';
|
||||||
|
import { TrackerGGService } from './tracker-gg.service';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import {
|
||||||
|
SportProgress,
|
||||||
|
SportGoal,
|
||||||
|
SportType,
|
||||||
|
} from '../models/sport-goal.model';
|
||||||
|
import { GamingProgress, GamingGoal } from '../models/gaming-goal.model';
|
||||||
|
import { getIdealProgress } from '../../shared/utils/date.utils';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class GoalsService {
|
||||||
|
private readonly stravaService = inject(StravaService);
|
||||||
|
private readonly riotService = inject(RiotService);
|
||||||
|
private readonly faceitService = inject(FaceitService);
|
||||||
|
private readonly trackerService = inject(TrackerGGService);
|
||||||
|
|
||||||
|
private readonly sportProgress = signal<SportProgress | null>(null);
|
||||||
|
private readonly gamingProgress = signal<GamingProgress | null>(null);
|
||||||
|
private readonly isLoading = signal(false);
|
||||||
|
private readonly loadingProgress = signal(0);
|
||||||
|
private readonly loadingText = signal('Initializing...');
|
||||||
|
|
||||||
|
private sportGoalsRequest$: Observable<SportProgress> | null = null;
|
||||||
|
private gamingGoalsRequest$: Observable<GamingProgress> | null = null;
|
||||||
|
|
||||||
|
readonly sportProgress$ = this.sportProgress.asReadonly();
|
||||||
|
readonly gamingProgress$ = this.gamingProgress.asReadonly();
|
||||||
|
readonly isLoading$ = this.isLoading.asReadonly();
|
||||||
|
readonly loadingProgress$ = this.loadingProgress.asReadonly();
|
||||||
|
readonly loadingText$ = this.loadingText.asReadonly();
|
||||||
|
|
||||||
|
readonly overallSportProgress = computed(() => {
|
||||||
|
const progress = this.sportProgress();
|
||||||
|
if (!progress) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return progress.overallPercentage;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly overallGamingProgress = computed(() => {
|
||||||
|
const progress = this.gamingProgress();
|
||||||
|
if (!progress) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const total =
|
||||||
|
progress.tft.progress +
|
||||||
|
progress.lol.progress +
|
||||||
|
progress.rocketLeague.progress +
|
||||||
|
progress.faceit.progress;
|
||||||
|
return total / 4;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all sport goals (cached after first load)
|
||||||
|
*/
|
||||||
|
loadSportGoals(forceRefresh = false): Observable<SportProgress> {
|
||||||
|
// Return cached data if available and not forcing refresh
|
||||||
|
if (!forceRefresh && this.sportProgress() !== null) {
|
||||||
|
return of(this.sportProgress()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return existing request if already in progress
|
||||||
|
if (this.sportGoalsRequest$ && !forceRefresh) {
|
||||||
|
return this.sportGoalsRequest$;
|
||||||
|
}
|
||||||
|
|
||||||
|
const goals = environment.goals.sport;
|
||||||
|
|
||||||
|
this.sportGoalsRequest$ = combineLatest({
|
||||||
|
bike: this.stravaService.getSportProgress('bike', goals.bike),
|
||||||
|
run: this.stravaService.getSportProgress('run', goals.run),
|
||||||
|
swim: this.stravaService.getSportProgress('swim', goals.swim),
|
||||||
|
}).pipe(
|
||||||
|
map((progress) => {
|
||||||
|
// Calculate overall percentage as average of the three sports
|
||||||
|
const overallPercentage = Math.min(
|
||||||
|
100,
|
||||||
|
(progress.bike.percentage + progress.run.percentage + progress.swim.percentage) / 3
|
||||||
|
);
|
||||||
|
|
||||||
|
const sportProgress: SportProgress = {
|
||||||
|
bike: progress.bike,
|
||||||
|
run: progress.run,
|
||||||
|
swim: progress.swim,
|
||||||
|
overallPercentage,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.sportProgress.set(sportProgress);
|
||||||
|
return sportProgress;
|
||||||
|
}),
|
||||||
|
catchError(() => {
|
||||||
|
const empty: SportProgress = {
|
||||||
|
bike: this.getEmptySportGoal('bike', goals.bike),
|
||||||
|
run: this.getEmptySportGoal('run', goals.run),
|
||||||
|
swim: this.getEmptySportGoal('swim', goals.swim),
|
||||||
|
overallPercentage: 0,
|
||||||
|
};
|
||||||
|
this.sportProgress.set(empty);
|
||||||
|
return of(empty);
|
||||||
|
}),
|
||||||
|
shareReplay(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.sportGoalsRequest$;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all gaming goals (cached after first load)
|
||||||
|
*/
|
||||||
|
loadGamingGoals(forceRefresh = false): Observable<GamingProgress> {
|
||||||
|
// Return cached data if available and not forcing refresh
|
||||||
|
if (!forceRefresh && this.gamingProgress() !== null) {
|
||||||
|
return of(this.gamingProgress()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return existing request if already in progress
|
||||||
|
if (this.gamingGoalsRequest$ && !forceRefresh) {
|
||||||
|
return this.gamingGoalsRequest$;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.gamingGoalsRequest$ = combineLatest({
|
||||||
|
tft: this.riotService.getTFTRank(),
|
||||||
|
lol: this.riotService.getLoLRank(),
|
||||||
|
rocketLeague: this.trackerService.getRocketLeagueRank(),
|
||||||
|
faceit: this.faceitService.getFaceitProgress(),
|
||||||
|
}).pipe(
|
||||||
|
map((progress) => {
|
||||||
|
const gamingProgress: GamingProgress = {
|
||||||
|
tft: progress.tft,
|
||||||
|
lol: progress.lol,
|
||||||
|
rocketLeague: progress.rocketLeague,
|
||||||
|
faceit: progress.faceit,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.gamingProgress.set(gamingProgress);
|
||||||
|
return gamingProgress;
|
||||||
|
}),
|
||||||
|
catchError(() => {
|
||||||
|
const empty: GamingProgress = {
|
||||||
|
tft: this.getEmptyGamingGoal('tft', 'Diamond'),
|
||||||
|
lol: this.getEmptyGamingGoal('lol', 'Diamond'),
|
||||||
|
rocketLeague: this.getEmptyGamingGoal('rocket-league', 'Champion'),
|
||||||
|
faceit: this.getEmptyGamingGoal('faceit', 'Level 10'),
|
||||||
|
};
|
||||||
|
this.gamingProgress.set(empty);
|
||||||
|
return of(empty);
|
||||||
|
}),
|
||||||
|
shareReplay(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.gamingGoalsRequest$;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preload all data with progress tracking
|
||||||
|
*/
|
||||||
|
preloadAllData(): Observable<{ sport: SportProgress; gaming: GamingProgress }> {
|
||||||
|
this.isLoading.set(true);
|
||||||
|
this.loadingProgress.set(0);
|
||||||
|
this.loadingText.set('Loading sport data...');
|
||||||
|
|
||||||
|
const totalSteps = 7; // 3 sports + 4 gaming services
|
||||||
|
let completedSteps = 0;
|
||||||
|
|
||||||
|
const updateProgress = (stepName: string): void => {
|
||||||
|
completedSteps++;
|
||||||
|
const progress = Math.min(90, Math.round((completedSteps / totalSteps) * 85));
|
||||||
|
this.loadingProgress.set(progress);
|
||||||
|
this.loadingText.set(stepName);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create observables with progress tracking
|
||||||
|
const goals = environment.goals.sport;
|
||||||
|
const bike$ = this.stravaService.getSportProgress('bike', goals.bike).pipe(
|
||||||
|
map((result) => {
|
||||||
|
updateProgress('Loading biking data...');
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const run$ = this.stravaService.getSportProgress('run', goals.run).pipe(
|
||||||
|
map((result) => {
|
||||||
|
updateProgress('Loading running data...');
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const swim$ = this.stravaService.getSportProgress('swim', goals.swim).pipe(
|
||||||
|
map((result) => {
|
||||||
|
updateProgress('Loading swimming data...');
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const tft$ = this.riotService.getTFTRank().pipe(
|
||||||
|
map((result) => {
|
||||||
|
updateProgress('Loading TFT data...');
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const lol$ = this.riotService.getLoLRank().pipe(
|
||||||
|
map((result) => {
|
||||||
|
updateProgress('Loading LoL data...');
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const rocketLeague$ = this.trackerService.getRocketLeagueRank().pipe(
|
||||||
|
map((result) => {
|
||||||
|
updateProgress('Loading Rocket League data...');
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const faceit$ = this.faceitService.getFaceitProgress().pipe(
|
||||||
|
map((result) => {
|
||||||
|
updateProgress('Loading Faceit data...');
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return forkJoin({
|
||||||
|
sport: combineLatest({ bike: bike$, run: run$, swim: swim$ }).pipe(
|
||||||
|
map((progress) => {
|
||||||
|
const overallPercentage = Math.min(
|
||||||
|
100,
|
||||||
|
(progress.bike.percentage + progress.run.percentage + progress.swim.percentage) / 3
|
||||||
|
);
|
||||||
|
const sportProgress: SportProgress = {
|
||||||
|
bike: progress.bike,
|
||||||
|
run: progress.run,
|
||||||
|
swim: progress.swim,
|
||||||
|
overallPercentage,
|
||||||
|
};
|
||||||
|
this.sportProgress.set(sportProgress);
|
||||||
|
return sportProgress;
|
||||||
|
}),
|
||||||
|
catchError(() => {
|
||||||
|
const empty: SportProgress = {
|
||||||
|
bike: this.getEmptySportGoal('bike', goals.bike),
|
||||||
|
run: this.getEmptySportGoal('run', goals.run),
|
||||||
|
swim: this.getEmptySportGoal('swim', goals.swim),
|
||||||
|
overallPercentage: 0,
|
||||||
|
};
|
||||||
|
this.sportProgress.set(empty);
|
||||||
|
return of(empty);
|
||||||
|
})
|
||||||
|
),
|
||||||
|
gaming: combineLatest({
|
||||||
|
tft: tft$,
|
||||||
|
lol: lol$,
|
||||||
|
rocketLeague: rocketLeague$,
|
||||||
|
faceit: faceit$,
|
||||||
|
}).pipe(
|
||||||
|
map((progress) => {
|
||||||
|
const gamingProgress: GamingProgress = {
|
||||||
|
tft: progress.tft,
|
||||||
|
lol: progress.lol,
|
||||||
|
rocketLeague: progress.rocketLeague,
|
||||||
|
faceit: progress.faceit,
|
||||||
|
};
|
||||||
|
this.gamingProgress.set(gamingProgress);
|
||||||
|
return gamingProgress;
|
||||||
|
}),
|
||||||
|
catchError(() => {
|
||||||
|
const empty: GamingProgress = {
|
||||||
|
tft: this.getEmptyGamingGoal('tft', 'Diamond'),
|
||||||
|
lol: this.getEmptyGamingGoal('lol', 'Diamond'),
|
||||||
|
rocketLeague: this.getEmptyGamingGoal('rocket-league', 'Champion'),
|
||||||
|
faceit: this.getEmptyGamingGoal('faceit', 'Level 10'),
|
||||||
|
};
|
||||||
|
this.gamingProgress.set(empty);
|
||||||
|
return of(empty);
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}).pipe(
|
||||||
|
map((result) => {
|
||||||
|
this.loadingText.set('Finalizing...');
|
||||||
|
this.loadingProgress.set(95);
|
||||||
|
this.isLoading.set(false);
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
this.isLoading.set(false);
|
||||||
|
this.loadingText.set('Error loading data');
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if on track for sport goals
|
||||||
|
*/
|
||||||
|
isOnTrackForSport(progress: SportProgress): boolean {
|
||||||
|
const ideal = getIdealProgress();
|
||||||
|
return progress.overallPercentage >= ideal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get projection data for charts
|
||||||
|
*/
|
||||||
|
getSportProjection(progress: SportProgress): {
|
||||||
|
actual: number;
|
||||||
|
ideal: number;
|
||||||
|
projected: number;
|
||||||
|
} {
|
||||||
|
const ideal = getIdealProgress();
|
||||||
|
const daysElapsed = this.getDaysElapsed();
|
||||||
|
const totalDays = 365;
|
||||||
|
|
||||||
|
if (daysElapsed === 0) {
|
||||||
|
return { actual: 0, ideal: 0, projected: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyAverage = progress.overallPercentage / daysElapsed;
|
||||||
|
const projected = dailyAverage * totalDays;
|
||||||
|
|
||||||
|
return {
|
||||||
|
actual: progress.overallPercentage,
|
||||||
|
ideal,
|
||||||
|
projected: Math.min(100, projected),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDaysElapsed(): number {
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date('2026-01-01');
|
||||||
|
const diff = now.getTime() - start.getTime();
|
||||||
|
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEmptySportGoal(type: SportType, target: number): SportGoal {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
target,
|
||||||
|
current: 0,
|
||||||
|
percentage: 0,
|
||||||
|
activities: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEmptyGamingGoal(
|
||||||
|
game: 'tft' | 'lol' | 'rocket-league' | 'faceit',
|
||||||
|
target: string
|
||||||
|
): GamingGoal {
|
||||||
|
return {
|
||||||
|
game,
|
||||||
|
target,
|
||||||
|
current: 'Unranked',
|
||||||
|
progress: 0,
|
||||||
|
recentMatches: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,946 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable, of, forkJoin, from } from 'rxjs';
|
||||||
|
import { map, catchError, switchMap, tap, shareReplay, concatMap, delay, toArray } from 'rxjs/operators';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import {
|
||||||
|
GamingGoal,
|
||||||
|
GamingStats,
|
||||||
|
Match,
|
||||||
|
RankInfo,
|
||||||
|
Streak,
|
||||||
|
} from '../models/gaming-goal.model';
|
||||||
|
import { RiotMatch } from '../models/api-response.model';
|
||||||
|
import {
|
||||||
|
RiotAccount,
|
||||||
|
RiotLeagueEntry,
|
||||||
|
RiotSummoner,
|
||||||
|
TFTMatch,
|
||||||
|
} from '../models/api-response.model';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class RiotService {
|
||||||
|
readonly #http = inject(HttpClient);
|
||||||
|
readonly #region = environment.riot.region;
|
||||||
|
readonly #baseUrl = `/api/proxy/riot/${this.#region}`;
|
||||||
|
readonly #accountBaseUrl = this.#getAccountRoutingUrl();
|
||||||
|
|
||||||
|
// Cache for account data per game type (5 minute TTL)
|
||||||
|
// Puuid is encrypted per app, so we need separate caches for LoL and TFT
|
||||||
|
#accountCache: Map<'lol' | 'tft', { data: RiotAccount; timestamp: number }> = new Map();
|
||||||
|
// Cache for account Observable per game type to prevent duplicate requests
|
||||||
|
#accountObservable$: Map<'lol' | 'tft', Observable<RiotAccount>> = new Map();
|
||||||
|
readonly #CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps region code to Account API routing value
|
||||||
|
*/
|
||||||
|
#getAccountRoutingUrl(): string {
|
||||||
|
const routingMap: Record<string, string> = {
|
||||||
|
na1: 'americas',
|
||||||
|
la1: 'americas',
|
||||||
|
la2: 'americas',
|
||||||
|
br1: 'americas',
|
||||||
|
euw1: 'europe',
|
||||||
|
eun1: 'europe',
|
||||||
|
tr1: 'europe',
|
||||||
|
ru: 'europe',
|
||||||
|
kr: 'asia',
|
||||||
|
jp1: 'asia',
|
||||||
|
oc1: 'sea',
|
||||||
|
ph2: 'sea',
|
||||||
|
sg2: 'sea',
|
||||||
|
th2: 'sea',
|
||||||
|
tw2: 'sea',
|
||||||
|
vn2: 'sea',
|
||||||
|
};
|
||||||
|
const routing = routingMap[this.#region] || 'europe';
|
||||||
|
return `/api/proxy/riot/${routing}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if API key is configured for a game type
|
||||||
|
*/
|
||||||
|
#hasApiKey(_gameType: 'lol' | 'tft'): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get LoL puuid (for Load More functionality)
|
||||||
|
*/
|
||||||
|
getLoLPuuid(): Observable<string | null> {
|
||||||
|
const gameName = environment.riot.summonerNames.lol;
|
||||||
|
const tagLine = environment.riot.tagLine;
|
||||||
|
if (!gameName || !tagLine || !this.#hasApiKey('lol')) {
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
return this.#getAccountByRiotId(gameName, tagLine, 'lol').pipe(
|
||||||
|
map((account) => account?.puuid || null),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get TFT puuid (for Load More functionality)
|
||||||
|
*/
|
||||||
|
getTFTPuuid(): Observable<string | null> {
|
||||||
|
const gameName = environment.riot.summonerNames.tft || environment.riot.summonerNames.lol;
|
||||||
|
const tagLine = environment.riot.tagLine;
|
||||||
|
if (!gameName || !tagLine || !this.#hasApiKey('tft')) {
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
return this.#getAccountByRiotId(gameName, tagLine, 'tft').pipe(
|
||||||
|
map((account) => account?.puuid || null),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get LoL rank and progress
|
||||||
|
*/
|
||||||
|
getLoLRank(): Observable<GamingGoal> {
|
||||||
|
const gameName = environment.riot.summonerNames.lol;
|
||||||
|
const tagLine = environment.riot.tagLine;
|
||||||
|
if (!gameName || !tagLine || !this.#hasApiKey('lol')) {
|
||||||
|
return of(this.#getEmptyGoal('lol', 'Diamond'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoL API only allows by-puuid method (not by-name)
|
||||||
|
return this.#getAccountByRiotId(gameName, tagLine, 'lol').pipe(
|
||||||
|
switchMap((account) => {
|
||||||
|
if (!account?.puuid || account.puuid === 'undefined') {
|
||||||
|
console.error('Invalid account puuid received:', account);
|
||||||
|
throw new Error('Failed to get valid account puuid');
|
||||||
|
}
|
||||||
|
console.log('[LoL] Account fetched, puuid:', account.puuid);
|
||||||
|
console.log('[LoL] Fetching summoner and league entry...');
|
||||||
|
return forkJoin({
|
||||||
|
rank: this.#getLeagueEntryByPuuid(account.puuid),
|
||||||
|
matches: this.#getLoLMatches(account.puuid),
|
||||||
|
}).pipe(
|
||||||
|
map((data) => {
|
||||||
|
console.log('[LoL] League entry data received:', data.rank);
|
||||||
|
const entry = data.rank[0];
|
||||||
|
const rankInfo: RankInfo = entry
|
||||||
|
? {
|
||||||
|
tier: entry.tier,
|
||||||
|
rank: entry.rank,
|
||||||
|
leaguePoints: entry.leaguePoints,
|
||||||
|
wins: entry.wins,
|
||||||
|
losses: entry.losses,
|
||||||
|
hotStreak: entry.hotStreak,
|
||||||
|
veteran: entry.veteran,
|
||||||
|
freshBlood: entry.freshBlood,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
tier: 'UNRANKED',
|
||||||
|
leaguePoints: 0,
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.#mapToGamingGoal('lol', 'Diamond', {
|
||||||
|
rank: rankInfo,
|
||||||
|
matches: data.matches,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Error fetching LoL rank:', error);
|
||||||
|
if (error.status === 400) {
|
||||||
|
console.warn(
|
||||||
|
'LoL API returned 400. The player may not have played LoL in this region (EUNE), ' +
|
||||||
|
'or the puuid is not valid for LoL endpoints. Showing unranked.'
|
||||||
|
);
|
||||||
|
} else if (error.status === 401 || error.status === 403) {
|
||||||
|
console.warn(
|
||||||
|
`Riot API returned ${error.status}. Your LoL API key may be expired or invalid. ` +
|
||||||
|
'Riot API keys expire after 24 hours. ' +
|
||||||
|
'Get a new key at: https://developer.riotgames.com/'
|
||||||
|
);
|
||||||
|
} else if (error.status === 429) {
|
||||||
|
console.warn(
|
||||||
|
'Riot API rate limit exceeded. Please wait before making more requests.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return of(this.#getEmptyGoal('lol', 'Diamond'));
|
||||||
|
}),
|
||||||
|
shareReplay(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get TFT rank and progress
|
||||||
|
*/
|
||||||
|
getTFTRank(): Observable<GamingGoal> {
|
||||||
|
const gameName =
|
||||||
|
environment.riot.summonerNames.tft ||
|
||||||
|
environment.riot.summonerNames.lol;
|
||||||
|
const tagLine = environment.riot.tagLine;
|
||||||
|
if (!gameName || !tagLine || !this.#hasApiKey('tft')) {
|
||||||
|
return of(this.#getEmptyGoal('tft', 'Diamond'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.#getAccountByRiotId(gameName, tagLine, 'tft').pipe(
|
||||||
|
switchMap((account) => {
|
||||||
|
if (!account?.puuid || account.puuid === 'undefined') {
|
||||||
|
console.error('Invalid account puuid received:', account);
|
||||||
|
throw new Error('Failed to get valid account puuid');
|
||||||
|
}
|
||||||
|
return forkJoin({
|
||||||
|
rank: this.#getTFTLeagueEntryByPuuid(account.puuid),
|
||||||
|
matches: this.#getTFTMatches(account.puuid),
|
||||||
|
}).pipe(
|
||||||
|
map((data) => {
|
||||||
|
const entry = data.rank[0];
|
||||||
|
const rankInfo: RankInfo = entry
|
||||||
|
? {
|
||||||
|
tier: entry.tier,
|
||||||
|
rank: entry.rank,
|
||||||
|
leaguePoints: entry.leaguePoints,
|
||||||
|
wins: entry.wins,
|
||||||
|
losses: entry.losses,
|
||||||
|
hotStreak: entry.hotStreak,
|
||||||
|
veteran: entry.veteran,
|
||||||
|
freshBlood: entry.freshBlood,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
tier: 'UNRANKED',
|
||||||
|
leaguePoints: 0,
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.#mapToGamingGoal('tft', 'Diamond', {
|
||||||
|
rank: rankInfo,
|
||||||
|
matches: data.matches,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Error fetching TFT rank:', error);
|
||||||
|
return of(this.#getEmptyGoal('tft', 'Diamond'));
|
||||||
|
}),
|
||||||
|
shareReplay(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get account by Riot ID (gameName and tagLine)
|
||||||
|
* This is the new way to get puuid
|
||||||
|
* @param gameType - Used to determine which API key to use
|
||||||
|
*/
|
||||||
|
#getAccountByRiotId(
|
||||||
|
gameName: string,
|
||||||
|
tagLine: string,
|
||||||
|
gameType: 'lol' | 'tft'
|
||||||
|
): Observable<RiotAccount> {
|
||||||
|
// Check data cache first for this specific game type
|
||||||
|
const cached = this.#accountCache.get(gameType);
|
||||||
|
if (cached) {
|
||||||
|
const cacheAge = Date.now() - cached.timestamp;
|
||||||
|
if (cacheAge < this.#CACHE_TTL) {
|
||||||
|
// Cache is still valid
|
||||||
|
return of(cached.data);
|
||||||
|
}
|
||||||
|
// Cache expired but exists - use it as fallback if request fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's already an in-flight request for this game type, return it
|
||||||
|
const existingObservable = this.#accountObservable$.get(gameType);
|
||||||
|
if (existingObservable) {
|
||||||
|
return existingObservable;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${this.#accountBaseUrl}/riot/account/v1/accounts/by-riot-id/${encodeURIComponent(gameName)}/${encodeURIComponent(tagLine)}`;
|
||||||
|
// API key is added by the interceptor based on X-Riot-Game-Type header
|
||||||
|
// Puuid is encrypted per app, so we fetch separately for each game type
|
||||||
|
const accountObservable$ = this.#http.get<RiotAccount>(url, {
|
||||||
|
headers: { 'X-Riot-Game-Type': gameType },
|
||||||
|
}).pipe(
|
||||||
|
tap((account) => {
|
||||||
|
// Cache successful response per game type
|
||||||
|
if (account?.puuid) {
|
||||||
|
this.#accountCache.set(gameType, { data: account, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
// Clear the Observable cache after completion
|
||||||
|
this.#accountObservable$.delete(gameType);
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
console.error(
|
||||||
|
`Error fetching account by Riot ID (${gameName}#${tagLine}) for ${gameType.toUpperCase()}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
if (error.status === 401 || error.status === 403) {
|
||||||
|
console.warn(
|
||||||
|
`Riot Account API returned ${error.status}. Your ${gameType.toUpperCase()} API key may be expired or invalid. ` +
|
||||||
|
'Riot API keys expire after 24 hours. ' +
|
||||||
|
'Get a new key at: https://developer.riotgames.com/'
|
||||||
|
);
|
||||||
|
// If we have cached data for this game type (even expired), use it as fallback
|
||||||
|
const cached = this.#accountCache.get(gameType);
|
||||||
|
if (cached?.data?.puuid) {
|
||||||
|
console.warn(
|
||||||
|
`Using cached ${gameType.toUpperCase()} account data as fallback. Please refresh your API key to get updated data.`
|
||||||
|
);
|
||||||
|
this.#accountObservable$.delete(gameType);
|
||||||
|
return of(cached.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clear the Observable cache on error
|
||||||
|
this.#accountObservable$.delete(gameType);
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
shareReplay(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store the Observable for this game type
|
||||||
|
this.#accountObservable$.set(gameType, accountObservable$);
|
||||||
|
return accountObservable$;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get summoner by name (region-specific, more reliable for LoL)
|
||||||
|
*/
|
||||||
|
#getSummonerByName(summonerName: string): Observable<RiotSummoner> {
|
||||||
|
if (!summonerName) {
|
||||||
|
throw new Error('Invalid summoner name provided');
|
||||||
|
}
|
||||||
|
const url = `${this.#baseUrl}/lol/summoner/v4/summoners/by-name/${encodeURIComponent(summonerName)}`;
|
||||||
|
// API key is added by the interceptor (detected from /lol/ path)
|
||||||
|
return this.#http.get<RiotSummoner>(url).pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error(
|
||||||
|
`Error fetching summoner by name ${summonerName}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
if (error.status === 404) {
|
||||||
|
console.warn(
|
||||||
|
'LoL summoner not found. The player may not have played LoL in this region (EUNE).'
|
||||||
|
);
|
||||||
|
} else if (error.status === 401 || error.status === 403) {
|
||||||
|
console.warn(
|
||||||
|
'LoL API returned ' + error.status + '. Your LoL API key may be expired or invalid.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
shareReplay(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get summoner by puuid (used for TFT, but may fail for LoL if player hasn't played in region)
|
||||||
|
*/
|
||||||
|
#getSummonerByPuuid(puuid: string): Observable<RiotSummoner> {
|
||||||
|
if (!puuid || puuid === 'undefined') {
|
||||||
|
console.error('getSummonerByPuuid called with invalid puuid:', puuid);
|
||||||
|
throw new Error('Invalid puuid provided');
|
||||||
|
}
|
||||||
|
const url = `${this.#baseUrl}/lol/summoner/v4/summoners/by-puuid/${encodeURIComponent(puuid)}`;
|
||||||
|
console.log('[LoL] Fetching summoner by puuid:', puuid);
|
||||||
|
// API key is added by the interceptor (detected from /lol/ path)
|
||||||
|
return this.#http.get<RiotSummoner>(url).pipe(
|
||||||
|
tap((summoner) => {
|
||||||
|
console.log('[LoL] Summoner fetched, id:', summoner?.id);
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
console.error(
|
||||||
|
`Error fetching summoner by puuid ${puuid}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
if (error.status === 400) {
|
||||||
|
console.warn(
|
||||||
|
'LoL summoner API returned 400. This might indicate: ' +
|
||||||
|
'1) The puuid is not valid for LoL in this region, ' +
|
||||||
|
'2) The player has not played LoL in this region, ' +
|
||||||
|
'3) The API endpoint format is incorrect.'
|
||||||
|
);
|
||||||
|
} else if (error.status === 401 || error.status === 403) {
|
||||||
|
console.warn(
|
||||||
|
'LoL API returned ' + error.status + '. Your LoL API key may be expired or invalid.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
shareReplay(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get LoL league entries by summoner ID
|
||||||
|
*/
|
||||||
|
#getLeagueEntryBySummonerId(
|
||||||
|
summonerId: string,
|
||||||
|
queueType: string
|
||||||
|
): Observable<RiotLeagueEntry[]> {
|
||||||
|
if (!summonerId) {
|
||||||
|
console.error('getLeagueEntryBySummonerId called with undefined summonerId');
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
const url = `${this.#baseUrl}/lol/league/v4/entries/by-summoner/${encodeURIComponent(summonerId)}`;
|
||||||
|
console.log('[LoL] Fetching league entry by summonerId:', summonerId, 'queueType:', queueType);
|
||||||
|
// API key is added by the interceptor (detected from /lol/ path)
|
||||||
|
return this.#http.get<RiotLeagueEntry[]>(url).pipe(
|
||||||
|
tap((entries) => {
|
||||||
|
console.log('[LoL] League entries received:', entries.length, 'entries');
|
||||||
|
}),
|
||||||
|
map((entries) => entries.filter((e) => e.queueType === queueType)),
|
||||||
|
catchError((error) => {
|
||||||
|
console.error(
|
||||||
|
`Error fetching league entry by summonerId ${summonerId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return of([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get LoL league entries by puuid directly
|
||||||
|
* Uses the direct endpoint: /lol/league/v4/entries/by-puuid/{encryptedPUUID}
|
||||||
|
* Prefers RANKED_SOLO_5x5, falls back to RANKED_FLEX_SR if SOLO doesn't exist
|
||||||
|
*/
|
||||||
|
#getLeagueEntryByPuuid(puuid: string): Observable<RiotLeagueEntry[]> {
|
||||||
|
if (!puuid || puuid === 'undefined') {
|
||||||
|
console.error('getLeagueEntryByPuuid called with invalid puuid:', puuid);
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
const url = `${this.#baseUrl}/lol/league/v4/entries/by-puuid/${encodeURIComponent(puuid)}`;
|
||||||
|
console.log('[LoL] Fetching league entry by puuid:', puuid);
|
||||||
|
// API key is added by the interceptor (detected from /lol/ path)
|
||||||
|
return this.#http.get<RiotLeagueEntry[]>(url).pipe(
|
||||||
|
tap((entries) => {
|
||||||
|
console.log('[LoL] League entries received:', entries.length, 'entries', entries);
|
||||||
|
}),
|
||||||
|
map((entries) => {
|
||||||
|
if (!entries || entries.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Prefer RANKED_SOLO_5x5, fall back to RANKED_FLEX_SR
|
||||||
|
const soloEntry = entries.find((e) => e.queueType === 'RANKED_SOLO_5x5');
|
||||||
|
if (soloEntry) {
|
||||||
|
return [soloEntry];
|
||||||
|
}
|
||||||
|
const flexEntry = entries.find((e) => e.queueType === 'RANKED_FLEX_SR');
|
||||||
|
if (flexEntry) {
|
||||||
|
return [flexEntry];
|
||||||
|
}
|
||||||
|
// If neither exists, return the first entry (or empty array)
|
||||||
|
return entries.length > 0 ? [entries[0]] : [];
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
console.error(
|
||||||
|
`Error fetching league entry by puuid ${puuid}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
if (error.status === 400) {
|
||||||
|
console.warn(
|
||||||
|
'LoL league API returned 400. The player may not have played LoL in this region, ' +
|
||||||
|
'or the puuid is not valid for LoL league endpoints.'
|
||||||
|
);
|
||||||
|
} else if (error.status === 401 || error.status === 403) {
|
||||||
|
console.warn(
|
||||||
|
'LoL API returned ' + error.status + '. Your LoL API key may be expired or invalid.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return of([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get TFT league entries by puuid
|
||||||
|
*/
|
||||||
|
#getTFTLeagueEntryByPuuid(
|
||||||
|
puuid: string
|
||||||
|
): Observable<RiotLeagueEntry[]> {
|
||||||
|
if (!puuid) {
|
||||||
|
console.error('getTFTLeagueEntryByPuuid called with undefined puuid');
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
// TFT endpoint: /tft/league/v1/by-puuid/{puuid} (no "entries" in path)
|
||||||
|
const url = `${this.#baseUrl}/tft/league/v1/by-puuid/${encodeURIComponent(puuid)}`;
|
||||||
|
// API key is added by the interceptor (detected from /tft/ path)
|
||||||
|
return this.#http.get<RiotLeagueEntry[]>(url).pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error(
|
||||||
|
`Error fetching TFT league entry by puuid ${puuid}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return of([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent LoL matches for a player
|
||||||
|
*/
|
||||||
|
#getLoLMatches(puuid: string): Observable<Match[]> {
|
||||||
|
if (!puuid || puuid === 'undefined') {
|
||||||
|
console.warn('getLoLMatches called with invalid puuid:', puuid);
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
// Match v5 API uses regional routing
|
||||||
|
const matchListUrl = `${this.#accountBaseUrl}/lol/match/v5/matches/by-puuid/${encodeURIComponent(puuid)}/ids`;
|
||||||
|
|
||||||
|
return this.#http.get<string[]>(matchListUrl, {
|
||||||
|
headers: { 'X-Riot-Game-Type': 'lol' },
|
||||||
|
params: { start: '0', count: '5', type: 'ranked' },
|
||||||
|
}).pipe(
|
||||||
|
switchMap((matchIds) => {
|
||||||
|
if (!matchIds?.length) {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
// Fetch matches in batches of 5 to avoid rate limiting
|
||||||
|
const matchIdsToFetch = matchIds.slice(0, 5);
|
||||||
|
const batchSize = 5;
|
||||||
|
const batches: string[][] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < matchIdsToFetch.length; i += batchSize) {
|
||||||
|
batches.push(matchIdsToFetch.slice(i, i + batchSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process batches sequentially with delay between batches
|
||||||
|
return from(batches).pipe(
|
||||||
|
concatMap((batch, index) => {
|
||||||
|
const batchRequests = batch.map((matchId) =>
|
||||||
|
this.#http.get<RiotMatch>(
|
||||||
|
`${this.#accountBaseUrl}/lol/match/v5/matches/${matchId}`,
|
||||||
|
{ headers: { 'X-Riot-Game-Type': 'lol' } }
|
||||||
|
).pipe(catchError(() => of(null)))
|
||||||
|
);
|
||||||
|
|
||||||
|
return forkJoin(batchRequests).pipe(
|
||||||
|
// Add delay between batches (except first one)
|
||||||
|
delay(index === 0 ? 0 : 200)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
// Collect all batches into a single array
|
||||||
|
toArray(),
|
||||||
|
map((allBatches) => {
|
||||||
|
// Flatten array of arrays
|
||||||
|
const flattened = allBatches.flat();
|
||||||
|
return flattened
|
||||||
|
.filter((m): m is RiotMatch => m !== null)
|
||||||
|
.map((m) => this.#mapLoLMatch(m, puuid));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
if (error.status === 400) {
|
||||||
|
console.warn(
|
||||||
|
'LoL match list API returned 400. This might indicate: ' +
|
||||||
|
'1) The puuid is not valid for LoL in this region, ' +
|
||||||
|
'2) The player has no ranked matches, ' +
|
||||||
|
'3) The API endpoint format is incorrect.'
|
||||||
|
);
|
||||||
|
} else if (error.status === 401 || error.status === 403) {
|
||||||
|
console.warn(
|
||||||
|
'LoL match list API returned ' + error.status + '. Your LoL API key may be expired or invalid.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return of([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent TFT matches for a player
|
||||||
|
*/
|
||||||
|
#getTFTMatches(puuid: string): Observable<Match[]> {
|
||||||
|
if (!puuid) {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
// TFT Match v1 API
|
||||||
|
const matchListUrl = `${this.#accountBaseUrl}/tft/match/v1/matches/by-puuid/${encodeURIComponent(puuid)}/ids`;
|
||||||
|
|
||||||
|
return this.#http.get<string[]>(matchListUrl, {
|
||||||
|
headers: { 'X-Riot-Game-Type': 'tft' },
|
||||||
|
params: { start: '0', count: '5' },
|
||||||
|
}).pipe(
|
||||||
|
switchMap((matchIds) => {
|
||||||
|
if (!matchIds?.length) {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
// Get details for matches
|
||||||
|
const matchRequests = matchIds.slice(0, 5).map((matchId) =>
|
||||||
|
this.#http.get<TFTMatch>(
|
||||||
|
`${this.#accountBaseUrl}/tft/match/v1/matches/${matchId}`,
|
||||||
|
{ headers: { 'X-Riot-Game-Type': 'tft' } }
|
||||||
|
).pipe(catchError(() => of(null)))
|
||||||
|
);
|
||||||
|
return forkJoin(matchRequests).pipe(
|
||||||
|
map((matches) =>
|
||||||
|
matches
|
||||||
|
.filter((m): m is TFTMatch => m !== null)
|
||||||
|
.map((m) => this.#mapTFTMatch(m, puuid))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
catchError(() => of([]))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get more LoL matches (for Load More functionality)
|
||||||
|
*/
|
||||||
|
getMoreLoLMatches(puuid: string, startIndex: number, count: number = 5): Observable<Match[]> {
|
||||||
|
if (!puuid || puuid === 'undefined') {
|
||||||
|
console.warn('getMoreLoLMatches called with invalid puuid:', puuid);
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
const matchListUrl = `${this.#accountBaseUrl}/lol/match/v5/matches/by-puuid/${encodeURIComponent(puuid)}/ids`;
|
||||||
|
|
||||||
|
return this.#http.get<string[]>(matchListUrl, {
|
||||||
|
headers: { 'X-Riot-Game-Type': 'lol' },
|
||||||
|
params: { start: startIndex.toString(), count: count.toString(), type: 'ranked' },
|
||||||
|
}).pipe(
|
||||||
|
switchMap((matchIds) => {
|
||||||
|
if (!matchIds?.length) {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
// Fetch matches in batches of 5 to avoid rate limiting
|
||||||
|
const matchIdsToFetch = matchIds.slice(0, count);
|
||||||
|
const batchSize = 5;
|
||||||
|
const batches: string[][] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < matchIdsToFetch.length; i += batchSize) {
|
||||||
|
batches.push(matchIdsToFetch.slice(i, i + batchSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process batches sequentially with delay between batches
|
||||||
|
return from(batches).pipe(
|
||||||
|
concatMap((batch, index) => {
|
||||||
|
const batchRequests = batch.map((matchId) =>
|
||||||
|
this.#http.get<RiotMatch>(
|
||||||
|
`${this.#accountBaseUrl}/lol/match/v5/matches/${matchId}`,
|
||||||
|
{ headers: { 'X-Riot-Game-Type': 'lol' } }
|
||||||
|
).pipe(catchError(() => of(null)))
|
||||||
|
);
|
||||||
|
|
||||||
|
return forkJoin(batchRequests).pipe(
|
||||||
|
// Add delay between batches (except first one)
|
||||||
|
delay(index === 0 ? 0 : 200)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
// Collect all batches into a single array
|
||||||
|
toArray(),
|
||||||
|
map((allBatches) => {
|
||||||
|
// Flatten array of arrays
|
||||||
|
const flattened = allBatches.flat();
|
||||||
|
return flattened
|
||||||
|
.filter((m): m is RiotMatch => m !== null)
|
||||||
|
.map((m) => this.#mapLoLMatch(m, puuid));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Error fetching more LoL matches:', error);
|
||||||
|
return of([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get more TFT matches (for Load More functionality)
|
||||||
|
*/
|
||||||
|
getMoreTFTMatches(puuid: string, startIndex: number, count: number = 5): Observable<Match[]> {
|
||||||
|
if (!puuid || puuid === 'undefined') {
|
||||||
|
console.warn('getMoreTFTMatches called with invalid puuid:', puuid);
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
const matchListUrl = `${this.#accountBaseUrl}/tft/match/v1/matches/by-puuid/${encodeURIComponent(puuid)}/ids`;
|
||||||
|
|
||||||
|
return this.#http.get<string[]>(matchListUrl, {
|
||||||
|
headers: { 'X-Riot-Game-Type': 'tft' },
|
||||||
|
params: { start: startIndex.toString(), count: count.toString() },
|
||||||
|
}).pipe(
|
||||||
|
switchMap((matchIds) => {
|
||||||
|
if (!matchIds?.length) {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
// Get details for matches
|
||||||
|
const matchRequests = matchIds.slice(0, count).map((matchId) =>
|
||||||
|
this.#http.get<TFTMatch>(
|
||||||
|
`${this.#accountBaseUrl}/tft/match/v1/matches/${matchId}`,
|
||||||
|
{ headers: { 'X-Riot-Game-Type': 'tft' } }
|
||||||
|
).pipe(catchError(() => of(null)))
|
||||||
|
);
|
||||||
|
return forkJoin(matchRequests).pipe(
|
||||||
|
map((matches) =>
|
||||||
|
matches
|
||||||
|
.filter((m): m is TFTMatch => m !== null)
|
||||||
|
.map((m) => this.#mapTFTMatch(m, puuid))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Error fetching more TFT matches:', error);
|
||||||
|
return of([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Riot LoL match to Match model
|
||||||
|
*/
|
||||||
|
#mapLoLMatch(match: RiotMatch, puuid: string): Match {
|
||||||
|
const participant = match.info.participants.find((p) => p.puuid === puuid);
|
||||||
|
const isWin = participant?.win ?? false;
|
||||||
|
const gameName = environment.riot.summonerNames.lol;
|
||||||
|
const tagLine = environment.riot.tagLine;
|
||||||
|
|
||||||
|
// Build op.gg match URL
|
||||||
|
// Format: https://www.op.gg/summoners/{region}/{name}-{tag}/matches/{matchId}
|
||||||
|
const opggRegion = this.#getOpggRegion();
|
||||||
|
const matchUrl = `https://www.op.gg/summoners/${opggRegion}/${encodeURIComponent(gameName)}-${encodeURIComponent(tagLine)}/matches/${match.metadata.matchId}`;
|
||||||
|
|
||||||
|
// Note: Riot API doesn't provide LP changes, so we don't include lpChange
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: match.metadata.matchId,
|
||||||
|
game: 'lol',
|
||||||
|
date: new Date(match.info.gameEndTimestamp),
|
||||||
|
result: isWin ? 'win' : 'loss',
|
||||||
|
champion: participant?.championName,
|
||||||
|
kda: participant
|
||||||
|
? `${participant.kills}/${participant.deaths}/${participant.assists}`
|
||||||
|
: undefined,
|
||||||
|
duration: match.info.gameDuration,
|
||||||
|
matchUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map TFT match to Match model
|
||||||
|
*/
|
||||||
|
#mapTFTMatch(match: TFTMatch, puuid: string): Match {
|
||||||
|
const participant = match.info.participants.find((p) => p.puuid === puuid);
|
||||||
|
const placement = participant?.placement ?? 8;
|
||||||
|
// Top 4 is considered a win in TFT
|
||||||
|
const isWin = placement <= 4;
|
||||||
|
const gameName = environment.riot.summonerNames.tft ||
|
||||||
|
environment.riot.summonerNames.lol;
|
||||||
|
const tagLine = environment.riot.tagLine;
|
||||||
|
|
||||||
|
// Build tactics.tools match URL
|
||||||
|
// Format: https://tactics.tools/player/{region}/{name}/{tag}
|
||||||
|
const tacticsRegion = this.#getTacticsRegion();
|
||||||
|
const matchUrl = `https://tactics.tools/player/${tacticsRegion}/${encodeURIComponent(gameName)}/${encodeURIComponent(tagLine)}`;
|
||||||
|
|
||||||
|
// Note: Riot API doesn't provide LP changes, so we don't include lpChange
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: match.metadata.match_id,
|
||||||
|
game: 'tft',
|
||||||
|
date: new Date(match.info.game_datetime),
|
||||||
|
result: isWin ? 'win' : 'loss',
|
||||||
|
placement,
|
||||||
|
duration: match.info.game_length,
|
||||||
|
matchUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get op.gg region code from Riot region
|
||||||
|
*/
|
||||||
|
#getOpggRegion(): string {
|
||||||
|
const regionMap: Record<string, string> = {
|
||||||
|
na1: 'na',
|
||||||
|
euw1: 'euw',
|
||||||
|
eun1: 'eune',
|
||||||
|
kr: 'kr',
|
||||||
|
br1: 'br',
|
||||||
|
la1: 'lan',
|
||||||
|
la2: 'las',
|
||||||
|
oc1: 'oce',
|
||||||
|
ru: 'ru',
|
||||||
|
tr1: 'tr',
|
||||||
|
jp1: 'jp',
|
||||||
|
};
|
||||||
|
return regionMap[this.#region] || this.#region;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tactics.tools region code from Riot region
|
||||||
|
*/
|
||||||
|
#getTacticsRegion(): string {
|
||||||
|
const regionMap: Record<string, string> = {
|
||||||
|
na1: 'na',
|
||||||
|
euw1: 'euw',
|
||||||
|
eun1: 'eune',
|
||||||
|
kr: 'kr',
|
||||||
|
br1: 'br',
|
||||||
|
la1: 'lan',
|
||||||
|
la2: 'las',
|
||||||
|
oc1: 'oce',
|
||||||
|
ru: 'ru',
|
||||||
|
tr1: 'tr',
|
||||||
|
jp1: 'jp',
|
||||||
|
};
|
||||||
|
return regionMap[this.#region] || this.#region;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mapToGamingGoal(
|
||||||
|
game: 'lol' | 'tft',
|
||||||
|
target: string,
|
||||||
|
data: { rank: RankInfo; matches: Match[] }
|
||||||
|
): GamingGoal {
|
||||||
|
const current = this.#formatRank(data.rank);
|
||||||
|
const progress = this.#calculateRankProgress(data.rank, target);
|
||||||
|
const stats = this.#calculateStats(data.rank, data.matches, game);
|
||||||
|
|
||||||
|
return {
|
||||||
|
game,
|
||||||
|
target,
|
||||||
|
current,
|
||||||
|
progress,
|
||||||
|
recentMatches: data.matches,
|
||||||
|
rankInfo: data.rank,
|
||||||
|
stats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate gaming stats from rank info and matches
|
||||||
|
*/
|
||||||
|
#calculateStats(
|
||||||
|
rank: RankInfo,
|
||||||
|
matches: Match[],
|
||||||
|
game: 'lol' | 'tft'
|
||||||
|
): GamingStats {
|
||||||
|
const totalGames = rank.wins + rank.losses;
|
||||||
|
const winRate = totalGames > 0 ? (rank.wins / totalGames) * 100 : 0;
|
||||||
|
|
||||||
|
// Calculate streak from recent matches
|
||||||
|
const streak = this.#calculateStreak(matches);
|
||||||
|
|
||||||
|
// Count recent wins/losses
|
||||||
|
const recentWins = matches.filter((m) => m.result === 'win').length;
|
||||||
|
const recentLosses = matches.filter((m) => m.result === 'loss').length;
|
||||||
|
|
||||||
|
// Calculate average placement for TFT
|
||||||
|
const avgPlacement = game === 'tft' && matches.length > 0
|
||||||
|
? matches.reduce((sum, m) => sum + (m.placement ?? 0), 0) / matches.length
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
winRate,
|
||||||
|
totalGames,
|
||||||
|
recentWins,
|
||||||
|
recentLosses,
|
||||||
|
streak,
|
||||||
|
avgPlacement,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate current streak from recent matches
|
||||||
|
*/
|
||||||
|
#calculateStreak(matches: Match[]): Streak {
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return { type: 'none', count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstResult = matches[0].result;
|
||||||
|
if (firstResult === 'draw') {
|
||||||
|
return { type: 'none', count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const match of matches) {
|
||||||
|
if (match.result === firstResult) {
|
||||||
|
count++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: firstResult, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
#formatRank(rank: RankInfo): string {
|
||||||
|
if (rank.tier === 'UNRANKED') {
|
||||||
|
return 'Unranked';
|
||||||
|
}
|
||||||
|
return `${rank.tier} ${rank.rank || ''}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
#calculateRankProgress(rank: RankInfo, target: string): number {
|
||||||
|
const tierOrder = [
|
||||||
|
'IRON',
|
||||||
|
'BRONZE',
|
||||||
|
'SILVER',
|
||||||
|
'GOLD',
|
||||||
|
'PLATINUM',
|
||||||
|
'EMERALD',
|
||||||
|
'DIAMOND',
|
||||||
|
'MASTER',
|
||||||
|
'GRANDMASTER',
|
||||||
|
'CHALLENGER',
|
||||||
|
];
|
||||||
|
|
||||||
|
const targetIndex = tierOrder.indexOf(target.toUpperCase());
|
||||||
|
const currentIndex = tierOrder.indexOf(rank.tier);
|
||||||
|
|
||||||
|
if (currentIndex >= targetIndex) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIndex < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate overall progress from Iron to target tier
|
||||||
|
// Each tier has 4 divisions (IV, III, II, I), each division requires ~100 LP
|
||||||
|
const divisionOrder = ['IV', 'III', 'II', 'I'];
|
||||||
|
const currentDivisionIndex = divisionOrder.indexOf(rank.rank || 'IV');
|
||||||
|
|
||||||
|
// Calculate total divisions from Iron (index 0) to target tier
|
||||||
|
// Example: Diamond (index 6) = 6 tiers * 4 divisions = 24 divisions total
|
||||||
|
const totalDivisionsToTarget = targetIndex * 4;
|
||||||
|
|
||||||
|
// If already at or above target tier, return 100%
|
||||||
|
if (currentIndex >= targetIndex) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate how many FULL divisions we've completed from Iron to current position
|
||||||
|
// Completed tiers: currentIndex tiers (each with 4 divisions)
|
||||||
|
// Completed divisions in current tier: currentDivisionIndex
|
||||||
|
const completedTiersDivisions = currentIndex * 4;
|
||||||
|
const completedDivisionsInCurrentTier = currentDivisionIndex;
|
||||||
|
|
||||||
|
// Calculate LP progress within current division (0-100 LP per division)
|
||||||
|
const lpInDivision = Math.min(rank.leaguePoints, 100);
|
||||||
|
const lpProgressFraction = lpInDivision / 100; // 0.0 to 1.0
|
||||||
|
|
||||||
|
// Total completed divisions = completed tiers + completed divisions in current tier + LP progress
|
||||||
|
// Example: Emerald IV (index 5) with 46 LP targeting Diamond (index 6)
|
||||||
|
// - Completed tiers: 5 tiers * 4 = 20 divisions
|
||||||
|
// - Completed divisions in Emerald: 0 (we're in IV)
|
||||||
|
// - LP progress: 46/100 = 0.46 divisions
|
||||||
|
// - Total completed: 20 + 0 + 0.46 = 20.46 divisions
|
||||||
|
// - Total to target: 6 tiers * 4 = 24 divisions
|
||||||
|
// - Progress: 20.46 / 24 = 85.25%
|
||||||
|
const totalCompletedDivisions = completedTiersDivisions + completedDivisionsInCurrentTier + lpProgressFraction;
|
||||||
|
const totalProgress = (totalCompletedDivisions / totalDivisionsToTarget) * 100;
|
||||||
|
|
||||||
|
return Math.min(100, Math.max(0, totalProgress));
|
||||||
|
}
|
||||||
|
|
||||||
|
#getEmptyGoal(game: 'lol' | 'tft', target: string): GamingGoal {
|
||||||
|
return {
|
||||||
|
game,
|
||||||
|
target,
|
||||||
|
current: 'Unranked',
|
||||||
|
progress: 0,
|
||||||
|
recentMatches: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { map, catchError, tap, switchMap } from 'rxjs/operators';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
SportType,
|
||||||
|
SportGoal,
|
||||||
|
} from '../models/sport-goal.model';
|
||||||
|
import {
|
||||||
|
StravaActivity,
|
||||||
|
StravaTokenResponse,
|
||||||
|
} from '../models/api-response.model';
|
||||||
|
import { metersToKm, isIn2026 } from '../../shared/utils/date.utils';
|
||||||
|
|
||||||
|
const STRAVA_API_BASE = 'https://www.strava.com/api/v3';
|
||||||
|
const STRAVA_AUTH_BASE = '/api/auth/strava';
|
||||||
|
const STORAGE_KEY_TOKEN = 'strava_access_token';
|
||||||
|
const STORAGE_KEY_REFRESH = 'strava_refresh_token';
|
||||||
|
const STORAGE_KEY_EXPIRES = 'strava_token_expires';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class StravaService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate OAuth flow by redirecting to Strava
|
||||||
|
*/
|
||||||
|
initiateOAuth(): void {
|
||||||
|
const clientId = environment.strava.clientId;
|
||||||
|
const redirectUri = environment.strava.redirectUri;
|
||||||
|
const scope = 'activity:read_all';
|
||||||
|
const state = this.generateState();
|
||||||
|
|
||||||
|
const url = `${STRAVA_API_BASE}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${scope}&state=${state}`;
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange authorization code for access token (via backend proxy)
|
||||||
|
*/
|
||||||
|
exchangeCodeForToken(code: string): Observable<StravaTokenResponse> {
|
||||||
|
return this.http
|
||||||
|
.post<StravaTokenResponse>(`${STRAVA_AUTH_BASE}/token`, { code })
|
||||||
|
.pipe(
|
||||||
|
tap((response) => {
|
||||||
|
this.storeToken(response);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh access token (via backend proxy)
|
||||||
|
*/
|
||||||
|
refreshToken(): Observable<StravaTokenResponse> {
|
||||||
|
const refreshToken = this.getRefreshToken();
|
||||||
|
if (!refreshToken) {
|
||||||
|
return new Observable((observer) => {
|
||||||
|
observer.error(new Error('No refresh token available. Please re-authenticate.'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http
|
||||||
|
.post<StravaTokenResponse>(`${STRAVA_AUTH_BASE}/refresh`, { refresh_token: refreshToken })
|
||||||
|
.pipe(
|
||||||
|
tap((response) => {
|
||||||
|
// Always store refreshed token in localStorage (takes precedence over expired env token)
|
||||||
|
this.storeToken(response);
|
||||||
|
console.log('Strava token refreshed successfully');
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
// If refresh token is also invalid, clear stored tokens
|
||||||
|
if (error.status === 400 || error.status === 401) {
|
||||||
|
console.error('Refresh token is invalid. Please re-authenticate.');
|
||||||
|
this.logout(); // Clear invalid tokens
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if token is expired or about to expire (within 5 minutes)
|
||||||
|
*/
|
||||||
|
private isTokenExpired(): boolean {
|
||||||
|
const expires = this.getTokenExpires();
|
||||||
|
if (!expires) {
|
||||||
|
// If no expiration set, assume valid (but might be expired on server)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Consider expired if less than 5 minutes remaining
|
||||||
|
return Date.now() >= expires - 5 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure token is valid, refresh if needed
|
||||||
|
*/
|
||||||
|
private ensureValidToken(): Observable<string> {
|
||||||
|
const token = this.getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
return new Observable((observer) => {
|
||||||
|
observer.error(new Error('No access token available'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If token is expired or about to expire, refresh it
|
||||||
|
if (this.isTokenExpired()) {
|
||||||
|
const refreshToken = this.getRefreshToken();
|
||||||
|
if (refreshToken) {
|
||||||
|
return this.refreshToken().pipe(
|
||||||
|
map((response) => response.access_token),
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Failed to refresh token:', error);
|
||||||
|
// If refresh token is invalid, clear tokens and throw error
|
||||||
|
if (error.status === 400 || error.status === 401) {
|
||||||
|
this.logout();
|
||||||
|
throw new Error('Refresh token is invalid. Please re-authenticate.');
|
||||||
|
}
|
||||||
|
// For other errors, return original token as fallback
|
||||||
|
return of(token);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return of(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get activities for 2026
|
||||||
|
*/
|
||||||
|
getActivities(): Observable<Activity[]> {
|
||||||
|
return this.ensureValidToken().pipe(
|
||||||
|
switchMap((token) => {
|
||||||
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
|
const params = new HttpParams()
|
||||||
|
.set('per_page', '200')
|
||||||
|
.set('page', '1');
|
||||||
|
|
||||||
|
return this.http
|
||||||
|
.get<StravaActivity[]>(`${STRAVA_API_BASE}/athlete/activities`, {
|
||||||
|
headers,
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
map((activities) =>
|
||||||
|
activities
|
||||||
|
.filter((activity) => {
|
||||||
|
const date = new Date(activity.start_date);
|
||||||
|
return isIn2026(date);
|
||||||
|
})
|
||||||
|
.map((activity) => this.mapToActivity(activity))
|
||||||
|
.filter((activity): activity is Activity => activity !== null)
|
||||||
|
),
|
||||||
|
catchError((error) => {
|
||||||
|
// Try to refresh token on 401 (in case automatic refresh didn't work)
|
||||||
|
if (error.status === 401) {
|
||||||
|
const refreshToken = this.getRefreshToken();
|
||||||
|
if (refreshToken) {
|
||||||
|
return this.refreshToken().pipe(
|
||||||
|
switchMap((response) => {
|
||||||
|
const newHeaders = {
|
||||||
|
Authorization: `Bearer ${response.access_token}`,
|
||||||
|
};
|
||||||
|
return this.http.get<StravaActivity[]>(
|
||||||
|
`${STRAVA_API_BASE}/athlete/activities`,
|
||||||
|
{
|
||||||
|
headers: newHeaders,
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
map((activities) =>
|
||||||
|
activities
|
||||||
|
.filter((activity) => {
|
||||||
|
const date = new Date(activity.start_date);
|
||||||
|
return isIn2026(date);
|
||||||
|
})
|
||||||
|
.map((activity) => this.mapToActivity(activity))
|
||||||
|
.filter((activity): activity is Activity => activity !== null)
|
||||||
|
),
|
||||||
|
catchError((refreshError) => {
|
||||||
|
console.error(
|
||||||
|
'Error fetching Strava activities after refresh:',
|
||||||
|
refreshError
|
||||||
|
);
|
||||||
|
if (refreshError.status === 400 || refreshError.status === 401) {
|
||||||
|
console.error(
|
||||||
|
'Refresh token is also invalid. Please re-authenticate via OAuth.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return of([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
'No refresh token available. Please re-authenticate via OAuth.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error('Error fetching Strava activities:', error);
|
||||||
|
return of([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
catchError(() => {
|
||||||
|
console.error('No valid token available for Strava');
|
||||||
|
return of([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sport progress for a specific type
|
||||||
|
*/
|
||||||
|
getSportProgress(type: SportType, target: number): Observable<SportGoal> {
|
||||||
|
return this.getActivities().pipe(
|
||||||
|
map((activities) => {
|
||||||
|
const filtered = activities.filter((a) => a.type === type);
|
||||||
|
const totalDistance = filtered.reduce(
|
||||||
|
(sum, activity) => sum + metersToKm(activity.distance),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const percentage = Math.min(100, (totalDistance / target) * 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
target,
|
||||||
|
current: totalDistance,
|
||||||
|
percentage,
|
||||||
|
activities: filtered,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is authenticated
|
||||||
|
*/
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
const token = this.getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expires = this.getTokenExpires();
|
||||||
|
// If no expiration set (environment token), assume valid
|
||||||
|
if (!expires) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Date.now() < expires;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout - clear stored tokens
|
||||||
|
*/
|
||||||
|
logout(): void {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.removeItem(STORAGE_KEY_TOKEN);
|
||||||
|
localStorage.removeItem(STORAGE_KEY_REFRESH);
|
||||||
|
localStorage.removeItem(STORAGE_KEY_EXPIRES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToActivity(activity: StravaActivity): Activity | null {
|
||||||
|
const sportType = this.mapSportType(activity.sport_type || activity.type);
|
||||||
|
// If we can't categorize the activity, return null to exclude it
|
||||||
|
if (!sportType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: activity.id,
|
||||||
|
name: activity.name,
|
||||||
|
type: sportType,
|
||||||
|
distance: activity.distance,
|
||||||
|
startDate: new Date(activity.start_date),
|
||||||
|
movingTime: activity.moving_time,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Strava activity type to our SportType
|
||||||
|
* Returns null if activity type doesn't match bike, run, or swim
|
||||||
|
*/
|
||||||
|
private mapSportType(stravaType: string | undefined): SportType | null {
|
||||||
|
if (!stravaType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = stravaType.toLowerCase();
|
||||||
|
|
||||||
|
// Bike activities
|
||||||
|
if (
|
||||||
|
normalized === 'ride' ||
|
||||||
|
normalized === 'ebikeride' ||
|
||||||
|
normalized === 'handcycle' ||
|
||||||
|
normalized === 'virtualride' ||
|
||||||
|
normalized.includes('bike') ||
|
||||||
|
normalized.includes('cycle')
|
||||||
|
) {
|
||||||
|
return 'bike';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run activities
|
||||||
|
if (
|
||||||
|
normalized === 'run' ||
|
||||||
|
normalized === 'trailrun' ||
|
||||||
|
normalized === 'walk' ||
|
||||||
|
normalized.includes('run')
|
||||||
|
) {
|
||||||
|
return 'run';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swim activities
|
||||||
|
if (
|
||||||
|
normalized === 'swim' ||
|
||||||
|
normalized === 'openwaterswim' ||
|
||||||
|
normalized.includes('swim')
|
||||||
|
) {
|
||||||
|
return 'swim';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown activity type - don't default to bike, return null to exclude
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private storeToken(response: StravaTokenResponse): void {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem(STORAGE_KEY_TOKEN, response.access_token);
|
||||||
|
localStorage.setItem(STORAGE_KEY_REFRESH, response.refresh_token);
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY_EXPIRES,
|
||||||
|
String(response.expires_at * 1000)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAccessToken(): string | null {
|
||||||
|
// Check localStorage first (refreshed tokens take precedence)
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
const storedToken = localStorage.getItem(STORAGE_KEY_TOKEN);
|
||||||
|
if (storedToken) {
|
||||||
|
// Check if stored token is still valid
|
||||||
|
const storedExpires = localStorage.getItem(STORAGE_KEY_EXPIRES);
|
||||||
|
if (storedExpires) {
|
||||||
|
const expires = Number.parseInt(storedExpires, 10);
|
||||||
|
// If stored token is still valid (or expired but we'll refresh), use it
|
||||||
|
if (Date.now() < expires + 5 * 60 * 1000) {
|
||||||
|
return storedToken;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No expiration info, use it
|
||||||
|
return storedToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to environment token (for initial setup)
|
||||||
|
if (environment.strava.accessToken) {
|
||||||
|
return environment.strava.accessToken;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRefreshToken(): string | null {
|
||||||
|
// First check environment (for single-page app without login)
|
||||||
|
if (environment.strava.refreshToken) {
|
||||||
|
return environment.strava.refreshToken;
|
||||||
|
}
|
||||||
|
// Fall back to localStorage (from OAuth flow)
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return localStorage.getItem(STORAGE_KEY_REFRESH);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTokenExpires(): number | null {
|
||||||
|
// First check environment (for single-page app without login)
|
||||||
|
if (environment.strava.tokenExpiresAt) {
|
||||||
|
return environment.strava.tokenExpiresAt;
|
||||||
|
}
|
||||||
|
// Fall back to localStorage (from OAuth flow)
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const expires = localStorage.getItem(STORAGE_KEY_EXPIRES);
|
||||||
|
return expires ? Number.parseInt(expires, 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateState(): string {
|
||||||
|
return Math.random().toString(36).substring(2, 15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { map, catchError } from 'rxjs/operators';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { GamingGoal, Match } from '../models/gaming-goal.model';
|
||||||
|
import { TrackerGGProfile } from '../models/api-response.model';
|
||||||
|
|
||||||
|
const TRACKER_API_BASE =
|
||||||
|
'/api/proxy/tracker/api/v1/rocket-league/standard/profile';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class TrackerGGService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly platform = environment.tracker.rocketLeague.platform;
|
||||||
|
private readonly username = environment.tracker.rocketLeague.username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Rocket League rank and progress (via backend proxy)
|
||||||
|
*/
|
||||||
|
getRocketLeagueRank(): Observable<GamingGoal> {
|
||||||
|
if (!this.platform || !this.username) {
|
||||||
|
return of(this.getEmptyGoal());
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${TRACKER_API_BASE}/${this.platform}/${encodeURIComponent(this.username)}`;
|
||||||
|
|
||||||
|
return this.http
|
||||||
|
.get<TrackerGGProfile>(url)
|
||||||
|
.pipe(
|
||||||
|
map((profile) => {
|
||||||
|
// Check if response has expected structure
|
||||||
|
if (!profile || !profile.data || !profile.data.segments) {
|
||||||
|
console.warn('Unexpected Tracker.gg API response structure:', profile);
|
||||||
|
return this.getEmptyGoal();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rankSegment = profile.data.segments.find(
|
||||||
|
(s) => s.type === 'playlist'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rankSegment) {
|
||||||
|
console.warn('No playlist segment found in Tracker.gg response');
|
||||||
|
return this.getEmptyGoal();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rankName =
|
||||||
|
rankSegment.attributes?.rank?.metadata?.rankName || 'Unranked';
|
||||||
|
const tierName =
|
||||||
|
rankSegment.attributes?.rank?.metadata?.tierName || '';
|
||||||
|
const current = tierName
|
||||||
|
? `${tierName} ${rankName}`.trim()
|
||||||
|
: rankName;
|
||||||
|
|
||||||
|
const progress = this.calculateRankProgress(
|
||||||
|
tierName,
|
||||||
|
'Champion'
|
||||||
|
);
|
||||||
|
|
||||||
|
const goal: GamingGoal = {
|
||||||
|
game: 'rocket-league',
|
||||||
|
target: 'Champion',
|
||||||
|
current,
|
||||||
|
progress,
|
||||||
|
recentMatches: [],
|
||||||
|
};
|
||||||
|
return goal;
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Error fetching Rocket League rank:', error);
|
||||||
|
if (error.error) {
|
||||||
|
// Try to log the error response
|
||||||
|
if (typeof error.error === 'string') {
|
||||||
|
console.error('Error response (string):', error.error.substring(0, 500));
|
||||||
|
} else {
|
||||||
|
console.error('Error response (object):', error.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (error.status === 0) {
|
||||||
|
console.warn(
|
||||||
|
'CORS error detected. Tracker.gg API only works server-side (SSR). ' +
|
||||||
|
'Run the app with SSR to fetch Rocket League data.'
|
||||||
|
);
|
||||||
|
} else if (error.status === 200 && error.error) {
|
||||||
|
console.warn(
|
||||||
|
'Response parsing failed. The API might have returned HTML or invalid JSON. ' +
|
||||||
|
'This could indicate an invalid API key or incorrect endpoint.'
|
||||||
|
);
|
||||||
|
console.warn('Full error:', error);
|
||||||
|
} else if (error.status === 401 || error.status === 403) {
|
||||||
|
console.warn(
|
||||||
|
'Tracker.gg API authentication failed. Verify your API key is valid.'
|
||||||
|
);
|
||||||
|
} else if (error.status === 404) {
|
||||||
|
console.warn(
|
||||||
|
'Rocket League profile not found. Verify your platform and username are correct.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return of(this.getEmptyGoal());
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateRankProgress(current: string, target: string): number {
|
||||||
|
const rankOrder = [
|
||||||
|
'Unranked',
|
||||||
|
'Bronze',
|
||||||
|
'Silver',
|
||||||
|
'Gold',
|
||||||
|
'Platinum',
|
||||||
|
'Diamond',
|
||||||
|
'Champion',
|
||||||
|
'Grand Champion',
|
||||||
|
'Supersonic Legend',
|
||||||
|
];
|
||||||
|
|
||||||
|
const targetIndex = rankOrder.indexOf(target);
|
||||||
|
const currentIndex = rankOrder.findIndex((r) =>
|
||||||
|
current.toLowerCase().includes(r.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentIndex >= targetIndex) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIndex < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (currentIndex / targetIndex) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEmptyGoal(): GamingGoal {
|
||||||
|
return {
|
||||||
|
game: 'rocket-league',
|
||||||
|
target: 'Champion',
|
||||||
|
current: 'Unranked',
|
||||||
|
progress: 0,
|
||||||
|
recentMatches: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Component styles if needed
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
import { Component, OnInit, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { GoalsService } from '../../core/services/goals.service';
|
||||||
|
import { ThreeTrophyComponent } from '../../shared/components/three-trophy/three-trophy.component';
|
||||||
|
import { ThreeGeometricComponent } from '../../shared/components/three-geometric/three-geometric.component';
|
||||||
|
import { ProgressRingComponent } from '../../shared/components/progress-ring/progress-ring.component';
|
||||||
|
import { AnimatedCounterComponent } from '../../shared/components/animated-counter/animated-counter.component';
|
||||||
|
import { ConfettiComponent } from '../../shared/components/confetti/confetti.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dashboard',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterLink,
|
||||||
|
ThreeTrophyComponent,
|
||||||
|
ThreeGeometricComponent,
|
||||||
|
ProgressRingComponent,
|
||||||
|
AnimatedCounterComponent,
|
||||||
|
ConfettiComponent,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="dashboard-scroll-container">
|
||||||
|
<app-confetti *ngIf="showConfetti"></app-confetti>
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="full-page-section hero-section">
|
||||||
|
<app-three-geometric type="floating" color="#3b82f6"></app-three-geometric>
|
||||||
|
<div class="relative z-10 flex items-center justify-center h-full">
|
||||||
|
<div class="text-center px-4 max-w-5xl mx-auto">
|
||||||
|
<div class="mb-8 animate-fade-in-up">
|
||||||
|
<h1 class="text-7xl md:text-9xl font-black mb-6 bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent">
|
||||||
|
2026 Goals
|
||||||
|
</h1>
|
||||||
|
<p class="text-3xl md:text-4xl text-gray-700 font-medium mb-12">
|
||||||
|
Track your progress, achieve your dreams
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="!loading" class="animate-fade-in-up-delay">
|
||||||
|
<div class="text-9xl mb-8">🎯</div>
|
||||||
|
<div class="text-8xl font-black text-gray-900 mb-4">
|
||||||
|
{{ overallProgress.toFixed(1) }}%
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl text-gray-600 font-medium">Overall Progress</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce z-10">
|
||||||
|
<svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Trophy Section -->
|
||||||
|
<section class="full-page-section trophy-section">
|
||||||
|
<app-three-geometric type="rings" color="#8b5cf6"></app-three-geometric>
|
||||||
|
<div class="relative z-10 flex items-center justify-center h-full">
|
||||||
|
<div class="max-w-6xl mx-auto px-4 text-center">
|
||||||
|
<h2 class="section-title-large mb-6">Overall Achievement</h2>
|
||||||
|
<p class="section-subtitle-large mb-12">Your journey to success visualized</p>
|
||||||
|
<div class="bg-white/40 backdrop-blur-xl rounded-3xl shadow-2xl p-8 md:p-16 border border-white/30 inline-block">
|
||||||
|
<div class="h-[500px] w-[500px] max-w-full mx-auto">
|
||||||
|
<app-three-trophy [progress]="overallProgress" [size]="500"></app-three-trophy>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Sport Section -->
|
||||||
|
<section class="full-page-section sport-section">
|
||||||
|
<app-three-geometric type="floating" color="#3b82f6"></app-three-geometric>
|
||||||
|
<div class="relative z-10 flex items-center justify-center h-full">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 w-full">
|
||||||
|
<div class="max-w-2xl">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title-large mb-6">Sport Goals</h2>
|
||||||
|
<p class="section-subtitle-large mb-8">
|
||||||
|
Track your fitness journey across biking, running, and swimming
|
||||||
|
</p>
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="text-7xl font-black text-blue-600 mb-4">
|
||||||
|
{{ sportProgress.toFixed(1) }}%
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-white/30 rounded-full h-4 mb-4 overflow-hidden backdrop-blur-sm">
|
||||||
|
<div
|
||||||
|
class="bg-gradient-to-r from-blue-500 to-blue-700 h-4 rounded-full transition-all duration-1000 ease-out relative overflow-hidden"
|
||||||
|
[style.width.%]="Math.min(100, sportProgress)"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 w-full h-full animate-shimmer"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
(click)="toggleSportExpanded()"
|
||||||
|
class="px-8 py-4 bg-white/80 backdrop-blur-md text-blue-600 rounded-xl font-bold hover:bg-white transition-all transform hover:scale-105 shadow-xl text-lg mb-4 mr-4"
|
||||||
|
>
|
||||||
|
{{ sportExpanded() ? 'Show Less' : 'Learn More' }} ↓
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
routerLink="/sport"
|
||||||
|
class="px-8 py-4 bg-blue-600 text-white rounded-xl font-bold hover:bg-blue-700 transition-all transform hover:scale-105 shadow-xl text-lg inline-block"
|
||||||
|
>
|
||||||
|
View Details →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Expanded Content -->
|
||||||
|
<div
|
||||||
|
*ngIf="sportExpanded()"
|
||||||
|
class="mt-12 bg-white/40 backdrop-blur-xl rounded-3xl shadow-2xl p-8 md:p-12 border border-white/30 animate-expand"
|
||||||
|
>
|
||||||
|
<h3 class="text-3xl font-bold text-gray-900 mb-6">Sport Goals Breakdown</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div class="bg-white/60 rounded-2xl p-6">
|
||||||
|
<div class="text-4xl mb-4">🚴</div>
|
||||||
|
<h4 class="text-xl font-bold text-gray-900 mb-2">Biking</h4>
|
||||||
|
<p class="text-gray-600 mb-4">Target: 7,500 km</p>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||||
|
<div class="bg-blue-500 h-3 rounded-full" [style.width.%]="Math.min(100, sportProgress)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white/60 rounded-2xl p-6">
|
||||||
|
<div class="text-4xl mb-4">🏃</div>
|
||||||
|
<h4 class="text-xl font-bold text-gray-900 mb-2">Running</h4>
|
||||||
|
<p class="text-gray-600 mb-4">Target: 2,500 km</p>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||||
|
<div class="bg-blue-500 h-3 rounded-full" [style.width.%]="Math.min(100, sportProgress)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white/60 rounded-2xl p-6">
|
||||||
|
<div class="text-4xl mb-4">🏊</div>
|
||||||
|
<h4 class="text-xl font-bold text-gray-900 mb-2">Swimming</h4>
|
||||||
|
<p class="text-gray-600 mb-4">Target: 250 km</p>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||||
|
<div class="bg-blue-500 h-3 rounded-full" [style.width.%]="Math.min(100, sportProgress)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Gaming Section -->
|
||||||
|
<section class="full-page-section gaming-section">
|
||||||
|
<app-three-geometric type="rings" color="#8b5cf6"></app-three-geometric>
|
||||||
|
<div class="relative z-10 flex items-center justify-center h-full">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 w-full">
|
||||||
|
<div class="max-w-2xl">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title-large mb-6">Gaming Goals</h2>
|
||||||
|
<p class="section-subtitle-large mb-8">
|
||||||
|
Climb the ranks across multiple competitive games
|
||||||
|
</p>
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="text-7xl font-black text-purple-600 mb-4">
|
||||||
|
{{ gamingProgress.toFixed(1) }}%
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-white/30 rounded-full h-4 mb-4 overflow-hidden backdrop-blur-sm">
|
||||||
|
<div
|
||||||
|
class="bg-gradient-to-r from-purple-500 to-pink-600 h-4 rounded-full transition-all duration-1000 ease-out relative overflow-hidden"
|
||||||
|
[style.width.%]="Math.min(100, gamingProgress)"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 w-full h-full animate-shimmer"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
(click)="toggleGamingExpanded()"
|
||||||
|
class="px-8 py-4 bg-white/80 backdrop-blur-md text-purple-600 rounded-xl font-bold hover:bg-white transition-all transform hover:scale-105 shadow-xl text-lg mb-4 mr-4"
|
||||||
|
>
|
||||||
|
{{ gamingExpanded() ? 'Show Less' : 'Learn More' }} ↓
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
routerLink="/gaming"
|
||||||
|
class="px-8 py-4 bg-purple-600 text-white rounded-xl font-bold hover:bg-purple-700 transition-all transform hover:scale-105 shadow-xl text-lg inline-block"
|
||||||
|
>
|
||||||
|
View Details →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Expanded Content -->
|
||||||
|
<div
|
||||||
|
*ngIf="gamingExpanded()"
|
||||||
|
class="mt-12 bg-white/40 backdrop-blur-xl rounded-3xl shadow-2xl p-8 md:p-12 border border-white/30 animate-expand"
|
||||||
|
>
|
||||||
|
<h3 class="text-3xl font-bold text-gray-900 mb-6">Gaming Goals Breakdown</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
|
<div class="bg-white/60 rounded-2xl p-6 text-center">
|
||||||
|
<div class="text-4xl mb-4">🎮</div>
|
||||||
|
<h4 class="text-xl font-bold text-gray-900 mb-2">TFT</h4>
|
||||||
|
<p class="text-gray-600 mb-4">Target: Diamond</p>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||||
|
<div class="bg-purple-500 h-3 rounded-full" [style.width.%]="Math.min(100, gamingProgress)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white/60 rounded-2xl p-6 text-center">
|
||||||
|
<div class="text-4xl mb-4">⚔️</div>
|
||||||
|
<h4 class="text-xl font-bold text-gray-900 mb-2">LoL</h4>
|
||||||
|
<p class="text-gray-600 mb-4">Target: Diamond</p>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||||
|
<div class="bg-purple-500 h-3 rounded-full" [style.width.%]="Math.min(100, gamingProgress)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white/60 rounded-2xl p-6 text-center">
|
||||||
|
<div class="text-4xl mb-4">🚗</div>
|
||||||
|
<h4 class="text-xl font-bold text-gray-900 mb-2">Rocket League</h4>
|
||||||
|
<p class="text-gray-600 mb-4">Target: Champion</p>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||||
|
<div class="bg-purple-500 h-3 rounded-full" [style.width.%]="Math.min(100, gamingProgress)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white/60 rounded-2xl p-6 text-center">
|
||||||
|
<div class="text-4xl mb-4">🎯</div>
|
||||||
|
<h4 class="text-xl font-bold text-gray-900 mb-2">Faceit</h4>
|
||||||
|
<p class="text-gray-600 mb-4">Target: Level 10</p>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||||
|
<div class="bg-purple-500 h-3 rounded-full" [style.width.%]="Math.min(100, gamingProgress)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Stats Section -->
|
||||||
|
<section class="full-page-section stats-section">
|
||||||
|
<app-three-geometric type="particles" color="#10b981"></app-three-geometric>
|
||||||
|
<div class="relative z-10 flex items-center justify-center h-full">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 w-full">
|
||||||
|
<h2 class="section-title-large text-center mb-12">Quick Overview</h2>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="text-5xl mb-6">🏃</div>
|
||||||
|
<div class="mb-6 flex justify-center">
|
||||||
|
<app-progress-ring
|
||||||
|
[percentage]="sportProgress"
|
||||||
|
[size]="150"
|
||||||
|
[strokeWidth]="12"
|
||||||
|
progressColor="#3b82f6"
|
||||||
|
></app-progress-ring>
|
||||||
|
</div>
|
||||||
|
<h4 class="text-2xl font-bold text-gray-900 mb-3">Sport Goals</h4>
|
||||||
|
<div class="text-4xl font-black text-blue-600 mb-2">
|
||||||
|
<app-animated-counter
|
||||||
|
[value]="sportProgress"
|
||||||
|
[decimals]="1"
|
||||||
|
suffix="%"
|
||||||
|
color="#3b82f6"
|
||||||
|
></app-animated-counter>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600">Fitness progress</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="text-5xl mb-6">🎮</div>
|
||||||
|
<div class="mb-6 flex justify-center">
|
||||||
|
<app-progress-ring
|
||||||
|
[percentage]="gamingProgress"
|
||||||
|
[size]="150"
|
||||||
|
[strokeWidth]="12"
|
||||||
|
progressColor="#8b5cf6"
|
||||||
|
></app-progress-ring>
|
||||||
|
</div>
|
||||||
|
<h4 class="text-2xl font-bold text-gray-900 mb-3">Gaming Goals</h4>
|
||||||
|
<div class="text-4xl font-black text-purple-600 mb-2">
|
||||||
|
<app-animated-counter
|
||||||
|
[value]="gamingProgress"
|
||||||
|
[decimals]="1"
|
||||||
|
suffix="%"
|
||||||
|
color="#8b5cf6"
|
||||||
|
></app-animated-counter>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600">Rank progress</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="text-5xl mb-6">📊</div>
|
||||||
|
<div class="mb-6 flex justify-center">
|
||||||
|
<app-progress-ring
|
||||||
|
[percentage]="overallProgress"
|
||||||
|
[size]="150"
|
||||||
|
[strokeWidth]="12"
|
||||||
|
progressColor="#10b981"
|
||||||
|
></app-progress-ring>
|
||||||
|
</div>
|
||||||
|
<h4 class="text-2xl font-bold text-gray-900 mb-3">Overall</h4>
|
||||||
|
<div class="text-4xl font-black text-green-600 mb-2">
|
||||||
|
<app-animated-counter
|
||||||
|
[value]="overallProgress"
|
||||||
|
[decimals]="1"
|
||||||
|
suffix="%"
|
||||||
|
color="#10b981"
|
||||||
|
></app-animated-counter>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600">Total progress</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="text-5xl mb-6">⏰</div>
|
||||||
|
<div class="mb-6 flex items-center justify-center h-[150px]">
|
||||||
|
<div class="text-6xl font-black text-orange-600">
|
||||||
|
<app-animated-counter
|
||||||
|
[value]="daysRemaining"
|
||||||
|
[decimals]="0"
|
||||||
|
suffix=""
|
||||||
|
color="#f59e0b"
|
||||||
|
></app-animated-counter>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h4 class="text-2xl font-bold text-gray-900 mb-3">Days Remaining</h4>
|
||||||
|
<p class="text-gray-600">Until 2026 ends</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.dashboard-scroll-container {
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: scroll;
|
||||||
|
scroll-snap-type: y mandatory;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-page-section {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
scroll-snap-stop: always;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trophy-section {
|
||||||
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sport-section {
|
||||||
|
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gaming-section {
|
||||||
|
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section {
|
||||||
|
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-large {
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: white;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.section-title-large {
|
||||||
|
font-size: 5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle-large {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.section-subtitle-large {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-radius: 2rem;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-10px) scale(1.05);
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-up {
|
||||||
|
animation: fade-in-up 1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-up-delay {
|
||||||
|
animation: fade-in-up 1s ease-out 0.3s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes expand {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 1000px;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-expand {
|
||||||
|
animation: expand 0.5s ease-out;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-shimmer {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(255, 255, 255, 0.4) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 3s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar but keep functionality */
|
||||||
|
.dashboard-scroll-container::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-scroll-container::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-scroll-container::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-scroll-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class DashboardComponent implements OnInit {
|
||||||
|
loading = true;
|
||||||
|
sportProgress = 0;
|
||||||
|
gamingProgress = 0;
|
||||||
|
overallProgress = 0;
|
||||||
|
daysRemaining = 0;
|
||||||
|
showConfetti = false;
|
||||||
|
|
||||||
|
sportExpanded = signal(false);
|
||||||
|
gamingExpanded = signal(false);
|
||||||
|
|
||||||
|
readonly Math = Math;
|
||||||
|
|
||||||
|
constructor(private readonly goalsService: GoalsService) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadData();
|
||||||
|
this.calculateDaysRemaining();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSportExpanded(): void {
|
||||||
|
this.sportExpanded.set(!this.sportExpanded());
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleGamingExpanded(): void {
|
||||||
|
this.gamingExpanded.set(!this.gamingExpanded());
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData(): void {
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
this.goalsService.loadSportGoals().subscribe({
|
||||||
|
next: (progress) => {
|
||||||
|
this.sportProgress = progress.overallPercentage;
|
||||||
|
this.updateOverallProgress();
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.goalsService.loadGamingGoals().subscribe({
|
||||||
|
next: (progress) => {
|
||||||
|
this.gamingProgress =
|
||||||
|
(progress.tft.progress +
|
||||||
|
progress.lol.progress +
|
||||||
|
progress.rocketLeague.progress +
|
||||||
|
progress.faceit.progress) /
|
||||||
|
4;
|
||||||
|
this.updateOverallProgress();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
// Error already handled
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateOverallProgress(): void {
|
||||||
|
const newProgress = (this.sportProgress + this.gamingProgress) / 2;
|
||||||
|
const wasComplete = this.overallProgress >= 100;
|
||||||
|
this.overallProgress = newProgress;
|
||||||
|
|
||||||
|
if (!wasComplete && this.overallProgress >= 100) {
|
||||||
|
this.showConfetti = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.showConfetti = false;
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateDaysRemaining(): void {
|
||||||
|
const now = new Date();
|
||||||
|
const end = new Date('2026-12-31');
|
||||||
|
const diff = end.getTime() - now.getTime();
|
||||||
|
this.daysRemaining = Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
.gaming-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading State */
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 4px solid #e5e7eb;
|
||||||
|
border-top-color: #8b5cf6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overall Card */
|
||||||
|
.overall-card {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overall-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overall-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overall-value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed-badge {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed-count {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overall-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overall-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Games Grid */
|
||||||
|
.games-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Game Card */
|
||||||
|
.game-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-icon img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust filter for logos that need better visibility */
|
||||||
|
.tft-icon img {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lol-icon img {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-icon img {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceit-icon img {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tft-icon {
|
||||||
|
background: linear-gradient(135deg, #92400e 0%, #78350f 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(146, 64, 14, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lol-icon {
|
||||||
|
background: linear-gradient(135deg, #0ac8b9 0%, #0a7b72 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(10, 200, 185, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-icon {
|
||||||
|
background: linear-gradient(135deg, #0078d7 0%, #005a9e 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 120, 215, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceit-icon {
|
||||||
|
background: linear-gradient(135deg, #ff5500 0%, #cc4400 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 85, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceit-icon img {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-rank {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-streak-badge, .elo-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-streak-badge {
|
||||||
|
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elo-badge {
|
||||||
|
background: linear-gradient(135deg, #fed7aa 0%, #fdba74 100%);
|
||||||
|
color: #9a3412;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Section */
|
||||||
|
.progress-section {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-percent {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tft-fill { background: linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%); }
|
||||||
|
.lol-fill { background: linear-gradient(90deg, #3b82f6 0%, #1d4ed8 100%); }
|
||||||
|
.rl-fill { background: linear-gradient(90deg, #10b981 0%, #059669 100%); }
|
||||||
|
.faceit-fill { background: linear-gradient(90deg, #f97316 0%, #ea580c 100%); }
|
||||||
|
|
||||||
|
/* Stats Grid */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Streak Banner */
|
||||||
|
.streak-banner {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win-streak {
|
||||||
|
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loss-streak {
|
||||||
|
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Matches Section */
|
||||||
|
.matches-section {
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matches-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimated-tag {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #9ca3af;
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lp-change {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lp-change.positive {
|
||||||
|
color: #166534;
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lp-change.negative {
|
||||||
|
color: #991b1b;
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Promotion/Demotion styles */
|
||||||
|
.match-item.promotion {
|
||||||
|
border-left: 3px solid #fbbf24 !important;
|
||||||
|
background: linear-gradient(135deg, #fefce8 0%, #fef9c3 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item.demotion {
|
||||||
|
border-left: 3px solid #8b5cf6 !important;
|
||||||
|
background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-change-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-badge {
|
||||||
|
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||||
|
color: #92400e;
|
||||||
|
border: 1px solid #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demotion-badge {
|
||||||
|
background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%);
|
||||||
|
color: #5b21b6;
|
||||||
|
border: 1px solid #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item.promotion:hover {
|
||||||
|
background: linear-gradient(135deg, #fef9c3 0%, #fef08a 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item.demotion:hover {
|
||||||
|
background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matches-list-container {
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #d1d5db #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matches-list-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matches-list-container::-webkit-scrollbar-track {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matches-list-container::-webkit-scrollbar-thumb {
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matches-list-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matches-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4b5563;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-btn:hover {
|
||||||
|
background: linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%);
|
||||||
|
border-color: #9ca3af;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-btn:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item.win {
|
||||||
|
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
||||||
|
border-left: 3px solid #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item.loss {
|
||||||
|
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
|
||||||
|
border-left: 3px solid #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-result {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item.win .match-result { color: #166534; }
|
||||||
|
.match-item.loss .match-result { color: #991b1b; }
|
||||||
|
|
||||||
|
.match-champion, .match-score {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-details {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-kda {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-date {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-link-icon {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
transition: color 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item.clickable {
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item.clickable:hover {
|
||||||
|
transform: translateX(4px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item.clickable:hover .match-link-icon {
|
||||||
|
color: #6b7280;
|
||||||
|
transform: translate(2px, -2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item.win.clickable:hover {
|
||||||
|
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item.loss.clickable:hover {
|
||||||
|
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SSR Notice */
|
||||||
|
.ssr-notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No Data State */
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data-text {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #374151;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data-hint {
|
||||||
|
color: #9ca3af;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* New Stats Grid */
|
||||||
|
.stats-grid-new {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item-new {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value-new {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label-new {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart Center Text */
|
||||||
|
.chart-center-text {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-text-main {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-text-lp {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid-new {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overall-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+163
@@ -0,0 +1,163 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NgChartsModule } from 'ng2-charts';
|
||||||
|
import {
|
||||||
|
ChartConfiguration,
|
||||||
|
ChartData,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { SportProgress } from '../../../core/models/sport-goal.model';
|
||||||
|
import { ChartCardComponent } from '../../../shared/components/chart-card/chart-card.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-overall-progress-section',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, NgChartsModule, ChartCardComponent],
|
||||||
|
template: `
|
||||||
|
<div class="bg-gradient-to-br from-purple-50/50 via-pink-50/50 to-purple-100/50 backdrop-blur-sm rounded-2xl shadow-xl p-6 md:p-8 border-2 border-purple-200/50">
|
||||||
|
<div class="flex items-center gap-3 mb-6 pb-4 border-b-2 border-purple-200/30">
|
||||||
|
<span class="text-5xl">📊</span>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-2xl font-bold text-gray-900">Overall Progress</h3>
|
||||||
|
<p class="text-sm text-gray-600">2026 year overview and combined sports progress</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Year Progress and Sports Progress Row -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||||
|
<!-- Year Progress Card -->
|
||||||
|
<div class="bg-white/80 backdrop-blur-md rounded-xl shadow-lg p-6 border border-purple-200/50">
|
||||||
|
<h4 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<span class="text-2xl">📅</span>
|
||||||
|
<span>2026 Year Progress</span>
|
||||||
|
</h4>
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div class="h-48 w-48 relative">
|
||||||
|
<!-- Outer ring - Optimal -->
|
||||||
|
<canvas
|
||||||
|
baseChart
|
||||||
|
[data]="yearProgressOptimalDoughnutData"
|
||||||
|
[type]="'doughnut'"
|
||||||
|
[options]="yearProgressOptimalDoughnutOptions"
|
||||||
|
class="absolute inset-0"
|
||||||
|
></canvas>
|
||||||
|
<!-- Inner ring - Actual -->
|
||||||
|
<div class="absolute" style="inset: 20%;">
|
||||||
|
<canvas
|
||||||
|
baseChart
|
||||||
|
[data]="yearProgressDoughnutData"
|
||||||
|
[type]="'doughnut'"
|
||||||
|
[options]="yearProgressDoughnutOptions"
|
||||||
|
class="w-full h-full"
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||||
|
{{ progress.overallPercentage.toFixed(1) }}%
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 mt-1">Actual</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Legend -->
|
||||||
|
<div class="mt-4 flex flex-wrap justify-center gap-4 text-xs">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-3 h-3 rounded-full bg-blue-500"></div>
|
||||||
|
<span class="text-gray-600">Actual: {{ progress.overallPercentage.toFixed(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-3 h-3 rounded-full bg-gray-400"></div>
|
||||||
|
<span class="text-gray-600">Optimal: {{ idealProgress.toFixed(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-600 mt-3 font-medium">
|
||||||
|
<span class="text-base">⏱️</span> {{ daysElapsed }} / {{ totalDaysInYear }} days
|
||||||
|
</p>
|
||||||
|
<div *ngIf="daysBehind !== null" class="text-sm font-medium mt-3">
|
||||||
|
<span *ngIf="daysBehind > 0" class="bg-yellow-400 bg-opacity-30 px-3 py-1 rounded-full inline-block">
|
||||||
|
<span>⏰</span> {{ daysBehind.toFixed(0) }} day{{ daysBehind !== 1 ? 's' : '' }} behind schedule
|
||||||
|
</span>
|
||||||
|
<span *ngIf="daysBehind < 0" class="bg-green-400 bg-opacity-30 px-3 py-1 rounded-full inline-block">
|
||||||
|
<span>🚀</span> {{ Math.abs(daysBehind).toFixed(0) }} day{{ Math.abs(daysBehind) !== 1 ? 's' : '' }} ahead of schedule
|
||||||
|
</span>
|
||||||
|
<span *ngIf="daysBehind === 0" class="bg-green-400 bg-opacity-30 px-3 py-1 rounded-full inline-block">
|
||||||
|
<span>✨</span> Right on schedule!
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sports Progress Card -->
|
||||||
|
<div class="bg-gradient-to-r from-blue-500/90 via-purple-600/90 to-pink-600/90 backdrop-blur-md rounded-xl shadow-lg p-6 text-white relative overflow-hidden">
|
||||||
|
<div class="absolute top-0 right-0 w-32 h-32 bg-white opacity-10 rounded-full -mr-16 -mt-16"></div>
|
||||||
|
<div class="absolute bottom-0 left-0 w-24 h-24 bg-white opacity-10 rounded-full -ml-12 -mb-12"></div>
|
||||||
|
<div class="relative z-10">
|
||||||
|
<h4 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<span class="text-2xl">🎯</span>
|
||||||
|
<span>Sports Progress</span>
|
||||||
|
</h4>
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div class="h-48 w-full">
|
||||||
|
<canvas
|
||||||
|
baseChart
|
||||||
|
[data]="sportsProgressBikeDoughnutData"
|
||||||
|
[type]="'doughnut'"
|
||||||
|
[options]="sportsProgressBikeDoughnutOptions"
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-center">
|
||||||
|
<div class="text-2xl font-bold drop-shadow-lg">
|
||||||
|
{{ progress.overallPercentage.toFixed(1) }}%
|
||||||
|
</div>
|
||||||
|
<div class="text-xs opacity-90 mt-1">Overall</div>
|
||||||
|
</div>
|
||||||
|
<!-- Legend -->
|
||||||
|
<div class="mt-4 flex flex-wrap justify-center gap-4 text-xs">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-3 h-3 rounded-full bg-blue-500"></div>
|
||||||
|
<span class="opacity-90">Bike: {{ progress.bike.percentage.toFixed(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-3 h-3 rounded-full bg-green-500"></div>
|
||||||
|
<span class="opacity-90">Run: {{ progress.run.percentage.toFixed(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
|
||||||
|
<span class="opacity-90">Swim: {{ progress.swim.percentage.toFixed(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Combined Chart -->
|
||||||
|
<app-chart-card
|
||||||
|
title="Combined Progress Over Time"
|
||||||
|
[chartData]="combinedChartData"
|
||||||
|
[chartType]="'line'"
|
||||||
|
[chartOptions]="progressChartOptions"
|
||||||
|
></app-chart-card>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class OverallProgressSectionComponent {
|
||||||
|
@Input() progress!: SportProgress;
|
||||||
|
@Input() yearProgress = 0;
|
||||||
|
@Input() idealProgress = 0;
|
||||||
|
@Input() daysElapsed = 0;
|
||||||
|
@Input() totalDaysInYear = 365;
|
||||||
|
@Input() daysBehind: number | null = null;
|
||||||
|
@Input() isOnTrack = false;
|
||||||
|
@Input() yearProgressDoughnutData!: ChartData<'doughnut'>;
|
||||||
|
@Input() yearProgressOptimalDoughnutData!: ChartData<'doughnut'>;
|
||||||
|
@Input() sportsProgressBikeDoughnutData!: ChartData<'doughnut'>;
|
||||||
|
@Input() combinedChartData!: ChartData<'line'>;
|
||||||
|
@Input() yearProgressDoughnutOptions!: ChartConfiguration<'doughnut'>['options'];
|
||||||
|
@Input() yearProgressOptimalDoughnutOptions!: ChartConfiguration<'doughnut'>['options'];
|
||||||
|
@Input() sportsProgressBikeDoughnutOptions!: ChartConfiguration<'doughnut'>['options'];
|
||||||
|
@Input() progressChartOptions!: ChartConfiguration['options'];
|
||||||
|
|
||||||
|
readonly Math = Math;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Component styles if needed
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NgChartsModule } from 'ng2-charts';
|
||||||
|
import {
|
||||||
|
ChartConfiguration,
|
||||||
|
ChartData,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { SportGoal, Activity } from '../../../core/models/sport-goal.model';
|
||||||
|
import { ProgressCardComponent } from '../../../shared/components/progress-card/progress-card.component';
|
||||||
|
import { ChartCardComponent } from '../../../shared/components/chart-card/chart-card.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-sport-section',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, NgChartsModule, ProgressCardComponent, ChartCardComponent],
|
||||||
|
template: `
|
||||||
|
<div
|
||||||
|
class="backdrop-blur-sm rounded-2xl shadow-xl p-6 md:p-8 border-2 mt-8"
|
||||||
|
[ngClass]="gradientClass"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 mb-6 pb-4 border-b-2" [ngClass]="borderClass">
|
||||||
|
<span class="text-5xl">{{ emoji }}</span>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-2xl font-bold text-gray-900">{{ title }}</h3>
|
||||||
|
<p class="text-sm text-gray-600">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<app-progress-card
|
||||||
|
[title]="title"
|
||||||
|
[emoji]="emoji"
|
||||||
|
[percentage]="goal.percentage"
|
||||||
|
[currentLabel]="formatDistance(goal.current) + ' km'"
|
||||||
|
[targetLabel]="goal.target + ' km'"
|
||||||
|
[description]="
|
||||||
|
'Progress: ' +
|
||||||
|
formatDistance(goal.current) +
|
||||||
|
' / ' +
|
||||||
|
goal.target +
|
||||||
|
' km'
|
||||||
|
"
|
||||||
|
[estimatedCompletion]="estimatedCompletion"
|
||||||
|
[dailyKmNeeded]="dailyKmNeeded"
|
||||||
|
[currentDailyAvg]="currentDailyAvg"
|
||||||
|
[optimalDailyAvg]="optimalDailyAvg"
|
||||||
|
[daysAheadOrBehind]="daysAheadOrBehind"
|
||||||
|
[recentActivities]="recentActivities"
|
||||||
|
></app-progress-card>
|
||||||
|
<div class="bg-white/90 backdrop-blur-md rounded-xl shadow-lg p-6 border" [ngClass]="borderColorClass">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-semibold text-gray-900 mb-4 text-center">Progress</h4>
|
||||||
|
<div class="h-64">
|
||||||
|
<canvas
|
||||||
|
baseChart
|
||||||
|
[data]="doughnutData"
|
||||||
|
[type]="'doughnut'"
|
||||||
|
[options]="doughnutOptions"
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-center space-y-2">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
<span class="font-semibold text-gray-900">{{ goal.percentage.toFixed(1) }}%</span> Actual
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Optimal: <span class="font-semibold">{{ idealProgress.toFixed(1) }}%</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<app-chart-card
|
||||||
|
[title]="title + ' Progress Over Time'"
|
||||||
|
[chartData]="chartData"
|
||||||
|
[chartType]="'line'"
|
||||||
|
[chartOptions]="chartOptions"
|
||||||
|
></app-chart-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class SportSectionComponent {
|
||||||
|
@Input() title = '';
|
||||||
|
@Input() emoji = '';
|
||||||
|
@Input() description = '';
|
||||||
|
@Input() goal!: SportGoal;
|
||||||
|
@Input() estimatedCompletion: string | null = null;
|
||||||
|
@Input() dailyKmNeeded: number | null = null;
|
||||||
|
@Input() currentDailyAvg: number | null = null;
|
||||||
|
@Input() optimalDailyAvg: number | null = null;
|
||||||
|
@Input() daysAheadOrBehind: number | null = null;
|
||||||
|
@Input() recentActivities: Activity[] | null = null;
|
||||||
|
@Input() idealProgress = 0;
|
||||||
|
@Input() doughnutData!: ChartData<'doughnut'>;
|
||||||
|
@Input() chartData!: ChartData<'line'>;
|
||||||
|
@Input() doughnutOptions!: ChartConfiguration<'doughnut'>['options'];
|
||||||
|
@Input() chartOptions!: ChartConfiguration['options'];
|
||||||
|
@Input() gradientClass = '';
|
||||||
|
@Input() borderClass = '';
|
||||||
|
@Input() borderColorClass = '';
|
||||||
|
|
||||||
|
readonly Math = Math;
|
||||||
|
|
||||||
|
formatDistance(km: number): string {
|
||||||
|
return km.toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { StravaService } from '../../core/services/strava.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-strava-callback',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="flex justify-center items-center h-screen">
|
||||||
|
<div class="text-center">
|
||||||
|
<p *ngIf="loading" class="text-gray-600">Connecting to Strava...</p>
|
||||||
|
<p *ngIf="error" class="text-red-600">{{ error }}</p>
|
||||||
|
<p *ngIf="success" class="text-green-600">Successfully connected to Strava!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [],
|
||||||
|
})
|
||||||
|
export class StravaCallbackComponent implements OnInit {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly stravaService = inject(StravaService);
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
error: string | null = null;
|
||||||
|
success = false;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.route.queryParams.subscribe((params) => {
|
||||||
|
const code = params['code'];
|
||||||
|
const error = params['error'];
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
this.error = 'Authorization failed. Please try again.';
|
||||||
|
this.loading = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.router.navigate(['/sport']);
|
||||||
|
}, 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
this.exchangeCodeForToken(code);
|
||||||
|
} else {
|
||||||
|
this.error = 'No authorization code received.';
|
||||||
|
this.loading = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.router.navigate(['/sport']);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private exchangeCodeForToken(code: string): void {
|
||||||
|
this.stravaService.exchangeCodeForToken(code).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.success = true;
|
||||||
|
this.loading = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.router.navigate(['/sport']);
|
||||||
|
}, 2000);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.error = 'Failed to connect to Strava. Please try again.';
|
||||||
|
this.loading = false;
|
||||||
|
console.error('Error exchanging code for token:', err);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.router.navigate(['/sport']);
|
||||||
|
}, 3000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Component, Input, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-animated-counter',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<span class="font-bold" [style.color]="color">
|
||||||
|
{{ displayValue }}{{ suffix }}
|
||||||
|
</span>
|
||||||
|
`,
|
||||||
|
styles: [],
|
||||||
|
})
|
||||||
|
export class AnimatedCounterComponent implements OnInit, OnDestroy {
|
||||||
|
@Input() value: number = 0;
|
||||||
|
@Input() duration: number = 2000;
|
||||||
|
@Input() decimals: number = 1;
|
||||||
|
@Input() suffix: string = '';
|
||||||
|
@Input() color: string = '';
|
||||||
|
|
||||||
|
displayValue: number = 0;
|
||||||
|
private animationId: number | null = null;
|
||||||
|
private startTime: number = 0;
|
||||||
|
private startValue: number = 0;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.startValue = this.displayValue;
|
||||||
|
this.startTime = performance.now();
|
||||||
|
this.animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.animationId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private animate = (): void => {
|
||||||
|
const currentTime = performance.now();
|
||||||
|
const elapsed = currentTime - this.startTime;
|
||||||
|
const progress = Math.min(elapsed / this.duration, 1);
|
||||||
|
|
||||||
|
// Easing function (ease-out)
|
||||||
|
const easeOut = 1 - Math.pow(1 - progress, 3);
|
||||||
|
|
||||||
|
this.displayValue = this.startValue + (this.value - this.startValue) * easeOut;
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
this.animationId = requestAnimationFrame(this.animate);
|
||||||
|
} else {
|
||||||
|
this.displayValue = this.value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
ElementRef,
|
||||||
|
ViewChild,
|
||||||
|
AfterViewInit,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-basketball-game',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="basketball-game-container">
|
||||||
|
<canvas #canvas class="absolute inset-0 w-full h-full"></canvas>
|
||||||
|
<div class="absolute top-4 left-4 bg-black/70 text-white px-4 py-2 rounded-lg text-sm font-bold z-10">
|
||||||
|
Score: {{ score }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.basketball-game-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 50;
|
||||||
|
pointer-events: none;
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
cursor: crosshair;
|
||||||
|
background: transparent;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
pointer-events: auto;
|
||||||
|
touch-action: pan-y pinch-zoom;
|
||||||
|
}
|
||||||
|
.basketball-game-container > div {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BasketballGameComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
|
private scene!: THREE.Scene;
|
||||||
|
private camera!: THREE.OrthographicCamera;
|
||||||
|
private renderer!: THREE.WebGLRenderer;
|
||||||
|
private animationId: number | null = null;
|
||||||
|
private ball!: THREE.Mesh;
|
||||||
|
private hoop!: THREE.Group;
|
||||||
|
private isDragging = false;
|
||||||
|
private dragStart = new THREE.Vector2();
|
||||||
|
private ballVelocity = new THREE.Vector2();
|
||||||
|
private lastMouseX = 0;
|
||||||
|
private lastMouseY = 0;
|
||||||
|
private trajectory: THREE.Vector2[] = [];
|
||||||
|
private showTrajectory = false;
|
||||||
|
private trajectoryDots: THREE.Points[] = [];
|
||||||
|
score = 0;
|
||||||
|
private gravity = 0.4;
|
||||||
|
private bounce = 0.65;
|
||||||
|
private friction = 0.985;
|
||||||
|
private aspect = 1;
|
||||||
|
private readonly ballStartPos = { x: -15, y: 5 };
|
||||||
|
|
||||||
|
ngOnInit(): void {}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.initThree();
|
||||||
|
this.createScene();
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.animationId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
}
|
||||||
|
if (this.renderer) {
|
||||||
|
this.renderer.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private initThree(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
this.aspect = width / height;
|
||||||
|
|
||||||
|
this.scene = new THREE.Scene();
|
||||||
|
this.scene.background = null;
|
||||||
|
|
||||||
|
// Orthographic camera for 2D
|
||||||
|
const viewSize = 20;
|
||||||
|
this.camera = new THREE.OrthographicCamera(
|
||||||
|
-viewSize * this.aspect,
|
||||||
|
viewSize * this.aspect,
|
||||||
|
viewSize,
|
||||||
|
-viewSize,
|
||||||
|
0.1,
|
||||||
|
1000
|
||||||
|
);
|
||||||
|
this.camera.position.z = 10;
|
||||||
|
|
||||||
|
this.renderer = new THREE.WebGLRenderer({
|
||||||
|
canvas,
|
||||||
|
alpha: true,
|
||||||
|
antialias: true,
|
||||||
|
});
|
||||||
|
this.renderer.setSize(width, height);
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => this.onWindowResize());
|
||||||
|
}
|
||||||
|
|
||||||
|
private createScene(): void {
|
||||||
|
// Ball (2D circle)
|
||||||
|
const ballGeometry = new THREE.CircleGeometry(0.8, 32);
|
||||||
|
const ballMaterial = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xff8c00,
|
||||||
|
});
|
||||||
|
this.ball = new THREE.Mesh(ballGeometry, ballMaterial);
|
||||||
|
this.ball.position.set(this.ballStartPos.x, this.ballStartPos.y, 0);
|
||||||
|
this.scene.add(this.ball);
|
||||||
|
|
||||||
|
// Add basketball lines (2D)
|
||||||
|
const lineMaterial = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 2 });
|
||||||
|
|
||||||
|
// Horizontal line
|
||||||
|
const hLineGeometry = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
new THREE.Vector3(-0.8, 0, 0.01),
|
||||||
|
new THREE.Vector3(0.8, 0, 0.01),
|
||||||
|
]);
|
||||||
|
const hLine = new THREE.Line(hLineGeometry, lineMaterial);
|
||||||
|
this.ball.add(hLine);
|
||||||
|
|
||||||
|
// Vertical line
|
||||||
|
const vLineGeometry = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
new THREE.Vector3(0, -0.8, 0.01),
|
||||||
|
new THREE.Vector3(0, 0.8, 0.01),
|
||||||
|
]);
|
||||||
|
const vLine = new THREE.Line(vLineGeometry, lineMaterial);
|
||||||
|
this.ball.add(vLine);
|
||||||
|
|
||||||
|
// Hoop (on the right side, 2D) - make it VERY visible with filled shapes and larger size
|
||||||
|
this.hoop = new THREE.Group();
|
||||||
|
|
||||||
|
// Backboard (filled rectangle for visibility - larger)
|
||||||
|
const backboardShape = new THREE.Shape();
|
||||||
|
backboardShape.moveTo(17, 1.5);
|
||||||
|
backboardShape.lineTo(19, 1.5);
|
||||||
|
backboardShape.lineTo(19, 6.5);
|
||||||
|
backboardShape.lineTo(17, 6.5);
|
||||||
|
backboardShape.lineTo(17, 1.5);
|
||||||
|
const backboardGeometry = new THREE.ShapeGeometry(backboardShape);
|
||||||
|
const backboardMaterial = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xffffff,
|
||||||
|
transparent: false,
|
||||||
|
});
|
||||||
|
const backboard = new THREE.Mesh(backboardGeometry, backboardMaterial);
|
||||||
|
this.hoop.add(backboard);
|
||||||
|
|
||||||
|
// Backboard outline (thick line)
|
||||||
|
const backboardLineGeometry = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
new THREE.Vector3(17, 1.5, 0.01),
|
||||||
|
new THREE.Vector3(19, 1.5, 0.01),
|
||||||
|
new THREE.Vector3(19, 6.5, 0.01),
|
||||||
|
new THREE.Vector3(17, 6.5, 0.01),
|
||||||
|
new THREE.Vector3(17, 1.5, 0.01),
|
||||||
|
]);
|
||||||
|
const backboardLineMaterial = new THREE.LineBasicMaterial({
|
||||||
|
color: 0x000000,
|
||||||
|
linewidth: 5,
|
||||||
|
});
|
||||||
|
const backboardLine = new THREE.Line(backboardLineGeometry, backboardLineMaterial);
|
||||||
|
this.hoop.add(backboardLine);
|
||||||
|
|
||||||
|
// Rim (filled semi-circle for visibility - larger)
|
||||||
|
const rimRadius = 1.5; // Increased from 1.2
|
||||||
|
const rimShape = new THREE.Shape();
|
||||||
|
for (let i = 0; i <= 64; i++) {
|
||||||
|
const angle = (i / 64) * Math.PI;
|
||||||
|
const x = 18 + Math.cos(angle) * rimRadius;
|
||||||
|
const y = 4 + Math.sin(angle) * rimRadius;
|
||||||
|
if (i === 0) {
|
||||||
|
rimShape.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
rimShape.lineTo(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Close the shape
|
||||||
|
rimShape.lineTo(18 - rimRadius, 4);
|
||||||
|
rimShape.lineTo(18 + rimRadius, 4);
|
||||||
|
const rimGeometry = new THREE.ShapeGeometry(rimShape);
|
||||||
|
const rimMaterial = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xff0000, // Bright red
|
||||||
|
transparent: false,
|
||||||
|
});
|
||||||
|
const rim = new THREE.Mesh(rimGeometry, rimMaterial);
|
||||||
|
this.hoop.add(rim);
|
||||||
|
|
||||||
|
// Rim outline (thick line - larger)
|
||||||
|
const rimPoints: THREE.Vector3[] = [];
|
||||||
|
for (let i = 0; i <= 64; i++) {
|
||||||
|
const angle = (i / 64) * Math.PI;
|
||||||
|
rimPoints.push(new THREE.Vector3(18 + Math.cos(angle) * rimRadius, 4 + Math.sin(angle) * rimRadius, 0.01));
|
||||||
|
}
|
||||||
|
const rimLineGeometry = new THREE.BufferGeometry().setFromPoints(rimPoints);
|
||||||
|
const rimLineMaterial = new THREE.LineBasicMaterial({
|
||||||
|
color: 0x000000,
|
||||||
|
linewidth: 5,
|
||||||
|
});
|
||||||
|
const rimLine = new THREE.Line(rimLineGeometry, rimLineMaterial);
|
||||||
|
this.hoop.add(rimLine);
|
||||||
|
|
||||||
|
// Net (vertical lines - more visible and larger)
|
||||||
|
const netMaterial = new THREE.LineBasicMaterial({
|
||||||
|
color: 0xffffff,
|
||||||
|
linewidth: 4,
|
||||||
|
transparent: false,
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const x = 18 + Math.cos((i / 12) * Math.PI) * rimRadius;
|
||||||
|
const y = 4 + Math.sin((i / 12) * Math.PI) * rimRadius;
|
||||||
|
const netGeometry = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
new THREE.Vector3(x, y, 0.01),
|
||||||
|
new THREE.Vector3(x, y - 3, 0.01),
|
||||||
|
]);
|
||||||
|
const netLine = new THREE.Line(netGeometry, netMaterial);
|
||||||
|
this.hoop.add(netLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scene.add(this.hoop);
|
||||||
|
|
||||||
|
// Trajectory will be rendered as dots
|
||||||
|
|
||||||
|
// Floor line (subtle)
|
||||||
|
const floorGeometry = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
new THREE.Vector3(-20 * this.aspect, -8, 0),
|
||||||
|
new THREE.Vector3(20 * this.aspect, -8, 0),
|
||||||
|
]);
|
||||||
|
const floorMaterial = new THREE.LineBasicMaterial({
|
||||||
|
color: 0xffffff,
|
||||||
|
linewidth: 1,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.3
|
||||||
|
});
|
||||||
|
const floor = new THREE.Line(floorGeometry, floorMaterial);
|
||||||
|
this.scene.add(floor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventListeners(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
|
||||||
|
canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
|
||||||
|
canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
|
||||||
|
canvas.addEventListener('mouseup', (e) => this.onMouseUp(e));
|
||||||
|
canvas.addEventListener('mouseleave', (e) => this.onMouseUp(e));
|
||||||
|
|
||||||
|
// Allow wheel events to pass through for scrolling
|
||||||
|
canvas.addEventListener('wheel', (e) => {
|
||||||
|
// Don't prevent default - allow scrolling
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private screenToWorld(x: number, y: number): THREE.Vector2 {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mouseX = ((x - rect.left) / rect.width) * 2 - 1;
|
||||||
|
const mouseY = -((y - rect.top) / rect.height) * 2 + 1;
|
||||||
|
|
||||||
|
const viewSize = 20;
|
||||||
|
return new THREE.Vector2(
|
||||||
|
mouseX * viewSize * this.aspect,
|
||||||
|
mouseY * viewSize
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMouseDown(event: MouseEvent): void {
|
||||||
|
const worldPos = this.screenToWorld(event.clientX, event.clientY);
|
||||||
|
const distance = new THREE.Vector2(
|
||||||
|
worldPos.x - this.ball.position.x,
|
||||||
|
worldPos.y - this.ball.position.y
|
||||||
|
).length();
|
||||||
|
|
||||||
|
// Allow dragging if clicking near the ball (slingshot style)
|
||||||
|
if (distance < 1.5) {
|
||||||
|
this.isDragging = true;
|
||||||
|
// Stop the ball immediately when clicked
|
||||||
|
this.ballVelocity.set(0, 0);
|
||||||
|
// Reset ball to starting position
|
||||||
|
this.ball.position.set(this.ballStartPos.x, this.ballStartPos.y, 0);
|
||||||
|
// Store the ball's starting position as the anchor point
|
||||||
|
this.dragStart.set(this.ballStartPos.x, this.ballStartPos.y);
|
||||||
|
this.showTrajectory = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMouseMove(event: MouseEvent): void {
|
||||||
|
// Store last mouse position for onMouseUp
|
||||||
|
this.lastMouseX = event.clientX;
|
||||||
|
this.lastMouseY = event.clientY;
|
||||||
|
|
||||||
|
if (this.isDragging) {
|
||||||
|
const worldPos = this.screenToWorld(event.clientX, event.clientY);
|
||||||
|
// Calculate drag vector (from ball start position to mouse)
|
||||||
|
const dragVector = new THREE.Vector2().subVectors(worldPos, this.dragStart);
|
||||||
|
const dragLength = dragVector.length();
|
||||||
|
const maxDrag = 8;
|
||||||
|
|
||||||
|
if (dragLength > maxDrag) {
|
||||||
|
dragVector.normalize().multiplyScalar(maxDrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ball stays at starting position, only trajectory updates
|
||||||
|
this.ball.position.set(this.ballStartPos.x, this.ballStartPos.y, 0);
|
||||||
|
// Calculate trajectory based on drag vector
|
||||||
|
this.calculateTrajectory(dragVector);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMouseUp(event?: MouseEvent): void {
|
||||||
|
if (this.isDragging) {
|
||||||
|
this.isDragging = false;
|
||||||
|
this.showTrajectory = false;
|
||||||
|
|
||||||
|
// Get the final drag vector from last mouse position
|
||||||
|
let worldPos: THREE.Vector2;
|
||||||
|
if (event) {
|
||||||
|
worldPos = this.screenToWorld(event.clientX, event.clientY);
|
||||||
|
} else {
|
||||||
|
// Use stored last mouse position
|
||||||
|
worldPos = this.screenToWorld(this.lastMouseX, this.lastMouseY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear trajectory dots
|
||||||
|
this.trajectoryDots.forEach(dot => {
|
||||||
|
this.scene.remove(dot);
|
||||||
|
dot.geometry.dispose();
|
||||||
|
(dot.material as THREE.Material).dispose();
|
||||||
|
});
|
||||||
|
this.trajectoryDots = [];
|
||||||
|
|
||||||
|
// Calculate velocity in opposite direction of drag (slingshot)
|
||||||
|
const dragVector = new THREE.Vector2().subVectors(worldPos, this.dragStart);
|
||||||
|
const dragLength = dragVector.length();
|
||||||
|
|
||||||
|
if (dragLength > 0.1) {
|
||||||
|
// Velocity is opposite to drag direction (slingshot effect)
|
||||||
|
const power = Math.min(dragLength * 0.15, 1.5); // Much slower speed
|
||||||
|
const direction = dragVector.normalize().multiplyScalar(-1); // Opposite direction
|
||||||
|
this.ballVelocity.copy(direction.multiplyScalar(power));
|
||||||
|
} else {
|
||||||
|
this.ballVelocity.set(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ball is already at start position, no need to reset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateTrajectory(dragVector?: THREE.Vector2): void {
|
||||||
|
// Clear existing trajectory dots
|
||||||
|
this.trajectoryDots.forEach(dot => {
|
||||||
|
this.scene.remove(dot);
|
||||||
|
dot.geometry.dispose();
|
||||||
|
(dot.material as THREE.Material).dispose();
|
||||||
|
});
|
||||||
|
this.trajectoryDots = [];
|
||||||
|
|
||||||
|
// If dragVector is not provided, calculate it from current ball position
|
||||||
|
if (!dragVector) {
|
||||||
|
dragVector = new THREE.Vector2().subVectors(
|
||||||
|
new THREE.Vector2(this.ball.position.x, this.ball.position.y),
|
||||||
|
this.dragStart
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragLength = dragVector.length();
|
||||||
|
|
||||||
|
if (dragLength < 0.1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate velocity in opposite direction (slingshot)
|
||||||
|
const power = Math.min(dragLength * 0.15, 1.5); // Much slower speed
|
||||||
|
const direction = dragVector.clone().normalize().multiplyScalar(-1); // Opposite direction
|
||||||
|
const velocity = direction.multiplyScalar(power);
|
||||||
|
|
||||||
|
this.trajectory = [];
|
||||||
|
const pos = new THREE.Vector2(this.dragStart.x, this.dragStart.y);
|
||||||
|
const vel = new THREE.Vector2(velocity.x, velocity.y);
|
||||||
|
|
||||||
|
// Sample trajectory points every few steps
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
if (i % 3 === 0) { // Only add every 3rd point for dots
|
||||||
|
this.trajectory.push(new THREE.Vector2(pos.x, pos.y));
|
||||||
|
}
|
||||||
|
pos.add(vel);
|
||||||
|
vel.y -= this.gravity;
|
||||||
|
vel.multiplyScalar(this.friction);
|
||||||
|
|
||||||
|
if (pos.y < -8 || Math.abs(pos.x) > 20) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create dots for trajectory
|
||||||
|
this.trajectory.forEach((point, index) => {
|
||||||
|
const dotGeometry = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
new THREE.Vector3(point.x, point.y, 0)
|
||||||
|
]);
|
||||||
|
const dotMaterial = new THREE.PointsMaterial({
|
||||||
|
color: 0xffffff,
|
||||||
|
size: 0.3,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.7,
|
||||||
|
});
|
||||||
|
const dot = new THREE.Points(dotGeometry, dotMaterial);
|
||||||
|
this.scene.add(dot);
|
||||||
|
this.trajectoryDots.push(dot);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateBall(): void {
|
||||||
|
if (this.isDragging) return;
|
||||||
|
|
||||||
|
const speed = this.ballVelocity.length();
|
||||||
|
if (speed < 0.05) {
|
||||||
|
this.ballVelocity.set(0, 0);
|
||||||
|
// Reset ball to starting position when it stops
|
||||||
|
if (this.ball.position.x !== this.ballStartPos.x || this.ball.position.y !== this.ballStartPos.y) {
|
||||||
|
this.ball.position.set(this.ballStartPos.x, this.ballStartPos.y, 0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ball.position.x += this.ballVelocity.x;
|
||||||
|
this.ball.position.y += this.ballVelocity.y;
|
||||||
|
this.ballVelocity.y -= this.gravity;
|
||||||
|
this.ballVelocity.multiplyScalar(this.friction);
|
||||||
|
|
||||||
|
const viewSize = 20;
|
||||||
|
|
||||||
|
// Bounce off floor
|
||||||
|
if (this.ball.position.y < -7.2) {
|
||||||
|
this.ball.position.y = -7.2;
|
||||||
|
this.ballVelocity.y *= -this.bounce;
|
||||||
|
this.ballVelocity.x *= 0.9; // Reduce horizontal velocity on bounce
|
||||||
|
if (Math.abs(this.ballVelocity.y) < 0.2) {
|
||||||
|
this.ballVelocity.y = 0;
|
||||||
|
// Reset ball position if it stops
|
||||||
|
if (Math.abs(this.ballVelocity.x) < 0.15) {
|
||||||
|
this.ball.position.set(this.ballStartPos.x, this.ballStartPos.y, 0);
|
||||||
|
this.ballVelocity.set(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounce off walls
|
||||||
|
if (Math.abs(this.ball.position.x) > viewSize * this.aspect - 1) {
|
||||||
|
this.ballVelocity.x *= -this.bounce;
|
||||||
|
this.ball.position.x = Math.max(
|
||||||
|
-viewSize * this.aspect + 1,
|
||||||
|
Math.min(viewSize * this.aspect - 1, this.ball.position.x)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounce off top
|
||||||
|
if (this.ball.position.y > viewSize - 1) {
|
||||||
|
this.ballVelocity.y *= -this.bounce;
|
||||||
|
this.ball.position.y = viewSize - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check basket (right side, around y=4)
|
||||||
|
// Ball must pass through the rim area while moving downward
|
||||||
|
const rimCenterX = 18;
|
||||||
|
const rimCenterY = 4;
|
||||||
|
const rimRadius = 1.5; // Match the visual rim radius
|
||||||
|
const distFromRimCenter = Math.sqrt(
|
||||||
|
Math.pow(this.ball.position.x - rimCenterX, 2) +
|
||||||
|
Math.pow(this.ball.position.y - rimCenterY, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if ball passes through the rim (within radius and moving downward)
|
||||||
|
if (
|
||||||
|
distFromRimCenter < rimRadius + 0.5 &&
|
||||||
|
this.ball.position.y > 3 &&
|
||||||
|
this.ball.position.y < 5 &&
|
||||||
|
this.ballVelocity.y < 0.5 &&
|
||||||
|
this.ballVelocity.y > -3
|
||||||
|
) {
|
||||||
|
this.score++;
|
||||||
|
this.ballVelocity.set(0, 0);
|
||||||
|
this.ball.position.set(this.ballStartPos.x, this.ballStartPos.y, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private animate = (): void => {
|
||||||
|
this.animationId = requestAnimationFrame(this.animate);
|
||||||
|
this.updateBall();
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onWindowResize(): void {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
this.aspect = width / height;
|
||||||
|
|
||||||
|
const viewSize = 20;
|
||||||
|
this.camera.left = -viewSize * this.aspect;
|
||||||
|
this.camera.right = viewSize * this.aspect;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
this.renderer.setSize(width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
Input,
|
||||||
|
OnInit,
|
||||||
|
OnChanges,
|
||||||
|
SimpleChanges,
|
||||||
|
ViewChild,
|
||||||
|
AfterViewInit,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
BaseChartDirective,
|
||||||
|
NgChartsModule,
|
||||||
|
} from 'ng2-charts';
|
||||||
|
import {
|
||||||
|
ChartConfiguration,
|
||||||
|
ChartData,
|
||||||
|
ChartType,
|
||||||
|
} from 'chart.js';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-chart-card',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, NgChartsModule],
|
||||||
|
template: `
|
||||||
|
<div class="bg-white/80 backdrop-blur-md rounded-lg shadow-md p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1 border border-gray-200/50">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<span class="text-2xl">📈</span>
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
</h3>
|
||||||
|
<div class="h-80 md:h-96">
|
||||||
|
<canvas
|
||||||
|
baseChart
|
||||||
|
[data]="chartData"
|
||||||
|
[type]="chartType"
|
||||||
|
[options]="mergedOptions"
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [],
|
||||||
|
})
|
||||||
|
export class ChartCardComponent implements OnInit, OnChanges, AfterViewInit {
|
||||||
|
@Input() title = '';
|
||||||
|
@Input() chartData: ChartData = { datasets: [], labels: [] };
|
||||||
|
@Input() chartType: ChartType = 'line';
|
||||||
|
@Input() chartOptions: ChartConfiguration['options'] = {};
|
||||||
|
|
||||||
|
@ViewChild(BaseChartDirective) chart?: BaseChartDirective;
|
||||||
|
|
||||||
|
mergedOptions: ChartConfiguration['options'] = {};
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.updateOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
// Set initial zoom after chart is rendered
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setInitialZoom();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes['chartOptions'] || changes['chartData']) {
|
||||||
|
this.updateOptions();
|
||||||
|
if (this.chart) {
|
||||||
|
this.chart.update();
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setInitialZoom();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setInitialZoom(): void {
|
||||||
|
if (!this.chart || !this.chart.chart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chart = this.chart.chart;
|
||||||
|
const xScale = chart.scales?.['x'];
|
||||||
|
|
||||||
|
if (!xScale || !chart.data || !chart.data.labels) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate current week index
|
||||||
|
const now = new Date();
|
||||||
|
const startOfYear = new Date('2026-01-01');
|
||||||
|
const weeksSinceStart = Math.floor(
|
||||||
|
(now.getTime() - startOfYear.getTime()) / (1000 * 60 * 60 * 24 * 7)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show from week 0 to current week (add 1 because week 0 is included)
|
||||||
|
// If we're on week 3, we want to show weeks 0, 1, 2, 3 (4 weeks total)
|
||||||
|
const totalWeeks = chart.data.labels.length;
|
||||||
|
const startWeek = 0;
|
||||||
|
const endWeek = Math.min(totalWeeks - 1, weeksSinceStart + 1);
|
||||||
|
|
||||||
|
// Use zoom plugin's zoomScale method to set initial zoom
|
||||||
|
const zoomPlugin = (chart as any).plugins?.plugins?.zoom;
|
||||||
|
if (zoomPlugin) {
|
||||||
|
// Try using the zoom plugin's method
|
||||||
|
if (typeof (zoomPlugin as any).zoomScale === 'function') {
|
||||||
|
(zoomPlugin as any).zoomScale('x', {
|
||||||
|
min: startWeek,
|
||||||
|
max: endWeek,
|
||||||
|
});
|
||||||
|
} else if ((chart as any).zoomScale) {
|
||||||
|
(chart as any).zoomScale('x', {
|
||||||
|
min: startWeek,
|
||||||
|
max: endWeek,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also directly set scale options as fallback
|
||||||
|
if (xScale.options) {
|
||||||
|
(xScale.options as any).min = startWeek;
|
||||||
|
(xScale.options as any).max = endWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.update('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateOptions(): void {
|
||||||
|
const defaultOptions: ChartConfiguration['options'] = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'top',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mergedOptions = {
|
||||||
|
...defaultOptions,
|
||||||
|
...this.chartOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { Component, OnInit, OnDestroy, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
interface ConfettiParticle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
color: string;
|
||||||
|
size: number;
|
||||||
|
rotation: number;
|
||||||
|
rotationSpeed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-confetti',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<canvas #canvas class="fixed inset-0 pointer-events-none z-50"></canvas>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ConfettiComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
|
private ctx!: CanvasRenderingContext2D;
|
||||||
|
private particles: ConfettiParticle[] = [];
|
||||||
|
private animationId: number | null = null;
|
||||||
|
private colors = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#ef4444'];
|
||||||
|
|
||||||
|
ngOnInit(): void {}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
this.ctx = canvas.getContext('2d')!;
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
|
||||||
|
this.createParticles();
|
||||||
|
this.animate();
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => this.onResize());
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.animationId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', () => this.onResize());
|
||||||
|
}
|
||||||
|
|
||||||
|
private createParticles(): void {
|
||||||
|
const count = 150;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
this.particles.push({
|
||||||
|
x: Math.random() * window.innerWidth,
|
||||||
|
y: -10,
|
||||||
|
vx: (Math.random() - 0.5) * 4,
|
||||||
|
vy: Math.random() * 3 + 2,
|
||||||
|
color: this.colors[Math.floor(Math.random() * this.colors.length)],
|
||||||
|
size: Math.random() * 8 + 4,
|
||||||
|
rotation: Math.random() * Math.PI * 2,
|
||||||
|
rotationSpeed: (Math.random() - 0.5) * 0.2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private animate = (): void => {
|
||||||
|
this.animationId = requestAnimationFrame(this.animate);
|
||||||
|
|
||||||
|
this.ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
|
||||||
|
|
||||||
|
for (let i = this.particles.length - 1; i >= 0; i--) {
|
||||||
|
const p = this.particles[i];
|
||||||
|
|
||||||
|
p.x += p.vx;
|
||||||
|
p.y += p.vy;
|
||||||
|
p.vy += 0.1; // Gravity
|
||||||
|
p.rotation += p.rotationSpeed;
|
||||||
|
|
||||||
|
this.ctx.save();
|
||||||
|
this.ctx.translate(p.x, p.y);
|
||||||
|
this.ctx.rotate(p.rotation);
|
||||||
|
this.ctx.fillStyle = p.color;
|
||||||
|
this.ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size);
|
||||||
|
this.ctx.restore();
|
||||||
|
|
||||||
|
if (p.y > window.innerHeight + 10) {
|
||||||
|
this.particles.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.particles.length === 0) {
|
||||||
|
if (this.animationId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onResize(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
HostListener,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-custom-cursor',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div
|
||||||
|
class="cursor-dot"
|
||||||
|
[class.hover]="isHoveringClickable()"
|
||||||
|
[style.left.px]="x"
|
||||||
|
[style.top.px]="y"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="cursor-ring"
|
||||||
|
[class.hover]="isHoveringClickable()"
|
||||||
|
[style.left.px]="ringX"
|
||||||
|
[style.top.px]="ringY"
|
||||||
|
[style.width.px]="ringSize()"
|
||||||
|
[style.height.px]="ringSize()"
|
||||||
|
></div>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: translate(-50%, -50%) scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
from {
|
||||||
|
transform: translate(-50%, -50%) rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate(-50%, -50%) rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 5px rgba(255, 255, 255, 0.5),
|
||||||
|
0 0 10px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 10px rgba(255, 255, 255, 0.8),
|
||||||
|
0 0 20px rgba(255, 255, 255, 0.5),
|
||||||
|
0 0 30px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-dot {
|
||||||
|
position: fixed;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
mix-blend-mode: difference;
|
||||||
|
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||||
|
width 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||||
|
height 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-dot.hover {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
transform: translate(-50%, -50%) scale(1.5);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-ring {
|
||||||
|
position: fixed;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9998;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 2px solid white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
mix-blend-mode: difference;
|
||||||
|
transition: width 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||||
|
height 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||||
|
border-width 0.3s ease-out, border-color 0.3s ease-out,
|
||||||
|
opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-ring.hover {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
border-width: 3px;
|
||||||
|
border-color: rgba(255, 255, 255, 1);
|
||||||
|
opacity: 0.9;
|
||||||
|
animation: rotate 3s linear infinite, glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.cursor-dot,
|
||||||
|
.cursor-ring {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide default cursor */
|
||||||
|
:host {
|
||||||
|
cursor: none;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class CustomCursorComponent implements OnInit, OnDestroy {
|
||||||
|
x = 0;
|
||||||
|
y = 0;
|
||||||
|
ringX = 0;
|
||||||
|
ringY = 0;
|
||||||
|
private animationId: number | null = null;
|
||||||
|
private targetX = 0;
|
||||||
|
private targetY = 0;
|
||||||
|
readonly isHoveringClickable = signal(false);
|
||||||
|
private readonly clickableSelectors = [
|
||||||
|
'a',
|
||||||
|
'button',
|
||||||
|
'[role="button"]',
|
||||||
|
'input',
|
||||||
|
'textarea',
|
||||||
|
'select',
|
||||||
|
'[onclick]',
|
||||||
|
'[ng-click]',
|
||||||
|
'.clickable',
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.animate();
|
||||||
|
this.setupClickableDetection();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.animationId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
}
|
||||||
|
this.cleanupClickableDetection();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:mousemove', ['$event'])
|
||||||
|
onMouseMove(event: MouseEvent): void {
|
||||||
|
this.x = event.clientX;
|
||||||
|
this.y = event.clientY;
|
||||||
|
this.targetX = event.clientX;
|
||||||
|
this.targetY = event.clientY;
|
||||||
|
this.checkClickableElement(event.target as Element);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly isMouseDown = signal(false);
|
||||||
|
readonly ringSize = signal(40);
|
||||||
|
|
||||||
|
@HostListener('document:mousedown', [])
|
||||||
|
onMouseDown(): void {
|
||||||
|
this.isMouseDown.set(true);
|
||||||
|
this.updateRingSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:mouseup', [])
|
||||||
|
onMouseUp(): void {
|
||||||
|
this.isMouseDown.set(false);
|
||||||
|
this.updateRingSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateRingSize(): void {
|
||||||
|
const hovering = this.isHoveringClickable();
|
||||||
|
const mouseDown = this.isMouseDown();
|
||||||
|
|
||||||
|
if (mouseDown) {
|
||||||
|
this.ringSize.set(hovering ? 20 : 25);
|
||||||
|
} else {
|
||||||
|
this.ringSize.set(hovering ? 25 : 40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkClickableElement(element: Element | null): void {
|
||||||
|
if (!element) {
|
||||||
|
this.isHoveringClickable.set(false);
|
||||||
|
this.updateRingSize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isClickable =
|
||||||
|
element.matches(this.clickableSelectors) ||
|
||||||
|
element.closest(this.clickableSelectors) !== null;
|
||||||
|
|
||||||
|
this.isHoveringClickable.set(isClickable);
|
||||||
|
this.updateRingSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupClickableDetection(): void {
|
||||||
|
document.addEventListener('mouseover', this.handleMouseOver, true);
|
||||||
|
document.addEventListener('mouseout', this.handleMouseOut, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupClickableDetection(): void {
|
||||||
|
document.removeEventListener('mouseover', this.handleMouseOver, true);
|
||||||
|
document.removeEventListener('mouseout', this.handleMouseOut, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly handleMouseOver = (event: MouseEvent): void => {
|
||||||
|
this.checkClickableElement(event.target as Element);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly handleMouseOut = (event: MouseEvent): void => {
|
||||||
|
const relatedTarget = event.relatedTarget as Element | null;
|
||||||
|
if (!relatedTarget || !relatedTarget.closest(this.clickableSelectors)) {
|
||||||
|
this.isHoveringClickable.set(false);
|
||||||
|
this.updateRingSize();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private animate = (): void => {
|
||||||
|
this.animationId = requestAnimationFrame(this.animate);
|
||||||
|
|
||||||
|
// Smooth ring follow using lerp
|
||||||
|
this.ringX += (this.targetX - this.ringX) * 0.15;
|
||||||
|
this.ringY += (this.targetY - this.ringY) * 0.15;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
ElementRef,
|
||||||
|
ViewChild,
|
||||||
|
AfterViewInit,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
interface Duck {
|
||||||
|
mesh: THREE.Group;
|
||||||
|
velocity: THREE.Vector3;
|
||||||
|
alive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Bullet {
|
||||||
|
mesh: THREE.Mesh;
|
||||||
|
velocity: THREE.Vector3;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-duck-shooting-game',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="duck-shooting-game-container">
|
||||||
|
<canvas #canvas class="absolute inset-0 w-full h-full"></canvas>
|
||||||
|
<div class="crosshair" [style.left.px]="crosshairX" [style.top.px]="crosshairY"></div>
|
||||||
|
<div class="absolute top-2 left-2 bg-black/50 text-white px-2 py-1 rounded text-xs font-bold z-10">
|
||||||
|
Score: {{ score }} | Ducks: {{ ducksShot }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.duck-shooting-game-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 50;
|
||||||
|
pointer-events: none;
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
cursor: none !important;
|
||||||
|
background: transparent;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
pointer-events: auto;
|
||||||
|
touch-action: pan-y pinch-zoom;
|
||||||
|
}
|
||||||
|
.duck-shooting-game-container > div {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.crosshair {
|
||||||
|
position: fixed;
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 100;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 8px rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
.crosshair::before,
|
||||||
|
.crosshair::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: 0 0 3px rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
.crosshair::before {
|
||||||
|
left: 50%;
|
||||||
|
top: -4px;
|
||||||
|
width: 2px;
|
||||||
|
height: 8px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
.crosshair::after {
|
||||||
|
left: 50%;
|
||||||
|
bottom: -4px;
|
||||||
|
width: 2px;
|
||||||
|
height: 8px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class DuckShootingGameComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
|
private scene!: THREE.Scene;
|
||||||
|
private camera!: THREE.PerspectiveCamera;
|
||||||
|
private renderer!: THREE.WebGLRenderer;
|
||||||
|
private animationId: number | null = null;
|
||||||
|
private ducks: Duck[] = [];
|
||||||
|
private bullets: Bullet[] = [];
|
||||||
|
private raycaster = new THREE.Raycaster();
|
||||||
|
private mouse = new THREE.Vector2();
|
||||||
|
private duckSpawnTimer = 0;
|
||||||
|
private duckSpawnInterval = 2000; // Spawn every 2 seconds
|
||||||
|
crosshairX = 0;
|
||||||
|
crosshairY = 0;
|
||||||
|
score = 0;
|
||||||
|
ducksShot = 0;
|
||||||
|
gameOver = false;
|
||||||
|
private gameTime = 0;
|
||||||
|
private gameDuration = 60000; // 60 seconds
|
||||||
|
|
||||||
|
ngOnInit(): void {}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.initThree();
|
||||||
|
this.createScene();
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.animationId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
}
|
||||||
|
if (this.renderer) {
|
||||||
|
this.renderer.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private initThree(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
|
||||||
|
this.scene = new THREE.Scene();
|
||||||
|
this.scene.background = null;
|
||||||
|
|
||||||
|
this.camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
||||||
|
this.camera.position.set(0, 5, 20);
|
||||||
|
this.camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
this.renderer = new THREE.WebGLRenderer({
|
||||||
|
canvas,
|
||||||
|
alpha: true,
|
||||||
|
antialias: true,
|
||||||
|
});
|
||||||
|
this.renderer.setSize(width, height);
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
this.renderer.shadowMap.enabled = true;
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => this.onWindowResize());
|
||||||
|
}
|
||||||
|
|
||||||
|
private createScene(): void {
|
||||||
|
// Lighting
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.8);
|
||||||
|
this.scene.add(ambientLight);
|
||||||
|
|
||||||
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.9);
|
||||||
|
directionalLight.position.set(10, 10, 5);
|
||||||
|
directionalLight.castShadow = true;
|
||||||
|
this.scene.add(directionalLight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createDuck(): Duck {
|
||||||
|
const duck = new THREE.Group();
|
||||||
|
|
||||||
|
// Body (more duck-like shape - wider and flatter)
|
||||||
|
const bodyGeometry = new THREE.SphereGeometry(0.4, 16, 16);
|
||||||
|
bodyGeometry.scale(1.2, 0.8, 1.5);
|
||||||
|
const bodyMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xffa500,
|
||||||
|
roughness: 0.7,
|
||||||
|
});
|
||||||
|
const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
|
||||||
|
body.castShadow = true;
|
||||||
|
duck.add(body);
|
||||||
|
|
||||||
|
// Head (larger, more prominent)
|
||||||
|
const headGeometry = new THREE.SphereGeometry(0.3, 16, 16);
|
||||||
|
const headMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xffa500,
|
||||||
|
roughness: 0.7,
|
||||||
|
});
|
||||||
|
const head = new THREE.Mesh(headGeometry, headMaterial);
|
||||||
|
head.position.set(0, 0.4, 0.5);
|
||||||
|
head.castShadow = true;
|
||||||
|
duck.add(head);
|
||||||
|
|
||||||
|
// Beak (more prominent, duck-like)
|
||||||
|
const beakGeometry = new THREE.ConeGeometry(0.12, 0.25, 8);
|
||||||
|
beakGeometry.scale(1, 1, 1.3);
|
||||||
|
const beakMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xff8c00,
|
||||||
|
roughness: 0.5,
|
||||||
|
});
|
||||||
|
const beak = new THREE.Mesh(beakGeometry, beakMaterial);
|
||||||
|
beak.rotation.x = Math.PI / 2;
|
||||||
|
beak.position.set(0, 0.4, 0.75);
|
||||||
|
duck.add(beak);
|
||||||
|
|
||||||
|
// Left Wing
|
||||||
|
const wingGeometry = new THREE.SphereGeometry(0.25, 16, 16);
|
||||||
|
wingGeometry.scale(1.8, 0.6, 0.4);
|
||||||
|
const wingMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xff8c00,
|
||||||
|
roughness: 0.7,
|
||||||
|
});
|
||||||
|
const leftWing = new THREE.Mesh(wingGeometry, wingMaterial);
|
||||||
|
leftWing.position.set(0.35, 0, 0);
|
||||||
|
leftWing.castShadow = true;
|
||||||
|
duck.add(leftWing);
|
||||||
|
|
||||||
|
// Right Wing
|
||||||
|
const rightWing = new THREE.Mesh(wingGeometry, wingMaterial);
|
||||||
|
rightWing.position.set(-0.35, 0, 0);
|
||||||
|
rightWing.castShadow = true;
|
||||||
|
duck.add(rightWing);
|
||||||
|
|
||||||
|
// Left Eye
|
||||||
|
const eyeGeometry = new THREE.SphereGeometry(0.06, 8, 8);
|
||||||
|
const eyeMaterial = new THREE.MeshStandardMaterial({ color: 0x000000 });
|
||||||
|
const leftEye = new THREE.Mesh(eyeGeometry, eyeMaterial);
|
||||||
|
leftEye.position.set(0.12, 0.45, 0.6);
|
||||||
|
duck.add(leftEye);
|
||||||
|
|
||||||
|
// Right Eye
|
||||||
|
const rightEye = new THREE.Mesh(eyeGeometry, eyeMaterial);
|
||||||
|
rightEye.position.set(-0.12, 0.45, 0.6);
|
||||||
|
duck.add(rightEye);
|
||||||
|
|
||||||
|
// Tail (small)
|
||||||
|
const tailGeometry = new THREE.SphereGeometry(0.15, 12, 12);
|
||||||
|
tailGeometry.scale(0.8, 1.2, 0.6);
|
||||||
|
const tailMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xff8c00,
|
||||||
|
roughness: 0.7,
|
||||||
|
});
|
||||||
|
const tail = new THREE.Mesh(tailGeometry, tailMaterial);
|
||||||
|
tail.position.set(0, 0, -0.5);
|
||||||
|
duck.add(tail);
|
||||||
|
|
||||||
|
// Random starting position (off screen to the left or right)
|
||||||
|
const side = Math.random() > 0.5 ? 1 : -1;
|
||||||
|
duck.position.set(side * 30, Math.random() * 10 + 2, Math.random() * 10 - 5);
|
||||||
|
|
||||||
|
// Random velocity
|
||||||
|
const speed = 0.1 + Math.random() * 0.1;
|
||||||
|
const velocity = new THREE.Vector3(-side * speed, (Math.random() - 0.5) * 0.05, (Math.random() - 0.5) * 0.03);
|
||||||
|
|
||||||
|
// Rotate duck to face direction of travel (90 degrees offset for proper orientation)
|
||||||
|
const angle = Math.atan2(velocity.x, velocity.z);
|
||||||
|
duck.rotation.y = angle + Math.PI / 2;
|
||||||
|
|
||||||
|
this.scene.add(duck);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mesh: duck,
|
||||||
|
velocity,
|
||||||
|
alive: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createBullet(position: THREE.Vector3, direction: THREE.Vector3): Bullet {
|
||||||
|
const bulletGeometry = new THREE.SphereGeometry(0.1, 8, 8);
|
||||||
|
const bulletMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00 });
|
||||||
|
const bulletMesh = new THREE.Mesh(bulletGeometry, bulletMaterial);
|
||||||
|
bulletMesh.position.copy(position);
|
||||||
|
|
||||||
|
const speed = 1.5;
|
||||||
|
const velocity = direction.normalize().multiplyScalar(speed);
|
||||||
|
|
||||||
|
this.scene.add(bulletMesh);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mesh: bulletMesh,
|
||||||
|
velocity,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventListeners(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
|
||||||
|
canvas.addEventListener('mousemove', (e) => {
|
||||||
|
this.crosshairX = e.clientX;
|
||||||
|
this.crosshairY = e.clientY;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
canvas.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.shoot();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow wheel events to pass through for scrolling
|
||||||
|
canvas.addEventListener('wheel', (e) => {
|
||||||
|
// Don't prevent default - allow scrolling
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// Hide default cursor on the entire container
|
||||||
|
document.addEventListener('mousemove', () => {
|
||||||
|
if (this.canvasRef?.nativeElement) {
|
||||||
|
this.canvasRef.nativeElement.style.cursor = 'none';
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private shoot(): void {
|
||||||
|
if (this.gameOver) return;
|
||||||
|
|
||||||
|
this.raycaster.setFromCamera(this.mouse, this.camera);
|
||||||
|
const direction = this.raycaster.ray.direction.clone();
|
||||||
|
|
||||||
|
const bullet = this.createBullet(this.camera.position.clone(), direction);
|
||||||
|
this.bullets.push(bullet);
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnDuck(): void {
|
||||||
|
if (this.ducks.length < 10) {
|
||||||
|
this.ducks.push(this.createDuck());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateDucks(): void {
|
||||||
|
for (let i = this.ducks.length - 1; i >= 0; i--) {
|
||||||
|
const duck = this.ducks[i];
|
||||||
|
if (!duck.alive) continue;
|
||||||
|
|
||||||
|
duck.mesh.position.add(duck.velocity);
|
||||||
|
|
||||||
|
// Rotate duck to face direction of travel
|
||||||
|
const angle = Math.atan2(duck.velocity.x, duck.velocity.z);
|
||||||
|
duck.mesh.rotation.y = angle + Math.PI / 2;
|
||||||
|
|
||||||
|
// Animate wing flapping (both wings)
|
||||||
|
const leftWing = duck.mesh.children.find((child) => child.position.x > 0.3);
|
||||||
|
const rightWing = duck.mesh.children.find((child) => child.position.x < -0.3);
|
||||||
|
const flapAngle = Math.sin(Date.now() * 0.015 + i) * 0.4;
|
||||||
|
if (leftWing) {
|
||||||
|
leftWing.rotation.z = flapAngle;
|
||||||
|
}
|
||||||
|
if (rightWing) {
|
||||||
|
rightWing.rotation.z = -flapAngle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove if off screen
|
||||||
|
if (Math.abs(duck.mesh.position.x) > 40 || duck.mesh.position.y < -5 || duck.mesh.position.y > 15) {
|
||||||
|
this.scene.remove(duck.mesh);
|
||||||
|
duck.mesh.children.forEach((child) => {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.geometry.dispose();
|
||||||
|
if (Array.isArray(child.material)) {
|
||||||
|
child.material.forEach((m) => m.dispose());
|
||||||
|
} else {
|
||||||
|
child.material.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.ducks.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateBullets(): void {
|
||||||
|
for (let i = this.bullets.length - 1; i >= 0; i--) {
|
||||||
|
const bullet = this.bullets[i];
|
||||||
|
if (!bullet.active) continue;
|
||||||
|
|
||||||
|
bullet.mesh.position.add(bullet.velocity);
|
||||||
|
|
||||||
|
// Remove if too far
|
||||||
|
if (bullet.mesh.position.distanceTo(this.camera.position) > 100) {
|
||||||
|
this.scene.remove(bullet.mesh);
|
||||||
|
bullet.mesh.geometry.dispose();
|
||||||
|
(bullet.mesh.material as THREE.Material).dispose();
|
||||||
|
this.bullets.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check collision with ducks
|
||||||
|
for (const duck of this.ducks) {
|
||||||
|
if (!duck.alive) continue;
|
||||||
|
|
||||||
|
const distance = bullet.mesh.position.distanceTo(duck.mesh.position);
|
||||||
|
if (distance < 0.8) {
|
||||||
|
// Hit!
|
||||||
|
duck.alive = false;
|
||||||
|
bullet.active = false;
|
||||||
|
|
||||||
|
// Animate duck falling
|
||||||
|
duck.velocity.set(0, -0.2, 0);
|
||||||
|
duck.mesh.rotation.x = Math.PI / 2;
|
||||||
|
|
||||||
|
this.score += 10;
|
||||||
|
this.ducksShot++;
|
||||||
|
|
||||||
|
// Remove bullet
|
||||||
|
this.scene.remove(bullet.mesh);
|
||||||
|
bullet.mesh.geometry.dispose();
|
||||||
|
(bullet.mesh.material as THREE.Material).dispose();
|
||||||
|
this.bullets.splice(i, 1);
|
||||||
|
|
||||||
|
// Remove duck after a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
this.scene.remove(duck.mesh);
|
||||||
|
duck.mesh.children.forEach((child) => {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.geometry.dispose();
|
||||||
|
if (Array.isArray(child.material)) {
|
||||||
|
child.material.forEach((m) => m.dispose());
|
||||||
|
} else {
|
||||||
|
child.material.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const index = this.ducks.indexOf(duck);
|
||||||
|
if (index > -1) {
|
||||||
|
this.ducks.splice(index, 1);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private animate = (): void => {
|
||||||
|
this.animationId = requestAnimationFrame(this.animate);
|
||||||
|
|
||||||
|
if (!this.gameOver) {
|
||||||
|
this.gameTime += 16; // ~60fps
|
||||||
|
|
||||||
|
// Spawn ducks
|
||||||
|
this.duckSpawnTimer += 16;
|
||||||
|
if (this.duckSpawnTimer >= this.duckSpawnInterval) {
|
||||||
|
this.spawnDuck();
|
||||||
|
this.duckSpawnTimer = 0;
|
||||||
|
// Decrease spawn interval over time
|
||||||
|
this.duckSpawnInterval = Math.max(1000, this.duckSpawnInterval - 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto restart after game duration
|
||||||
|
if (this.gameTime >= this.gameDuration) {
|
||||||
|
this.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateDucks();
|
||||||
|
this.updateBullets();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onWindowResize(): void {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
|
||||||
|
this.camera.aspect = width / height;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
this.renderer.setSize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
restart(): void {
|
||||||
|
// Clean up existing ducks and bullets
|
||||||
|
this.ducks.forEach((duck) => {
|
||||||
|
this.scene.remove(duck.mesh);
|
||||||
|
duck.mesh.children.forEach((child) => {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.geometry.dispose();
|
||||||
|
if (Array.isArray(child.material)) {
|
||||||
|
child.material.forEach((m) => m.dispose());
|
||||||
|
} else {
|
||||||
|
child.material.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.ducks = [];
|
||||||
|
|
||||||
|
this.bullets.forEach((bullet) => {
|
||||||
|
this.scene.remove(bullet.mesh);
|
||||||
|
bullet.mesh.geometry.dispose();
|
||||||
|
(bullet.mesh.material as THREE.Material).dispose();
|
||||||
|
});
|
||||||
|
this.bullets = [];
|
||||||
|
|
||||||
|
this.score = 0;
|
||||||
|
this.ducksShot = 0;
|
||||||
|
this.gameOver = false;
|
||||||
|
this.gameTime = 0;
|
||||||
|
this.duckSpawnTimer = 0;
|
||||||
|
this.duckSpawnInterval = 2000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-footer',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink],
|
||||||
|
template: `
|
||||||
|
<footer class="bg-white/80 backdrop-blur-md border-t-2 border-blue-200 mt-auto">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
<!-- Brand Section -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-3xl">🎯</span>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900">2026 Goals Tracker</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 text-sm">
|
||||||
|
Track your sport and gaming goals for 2026. Stay motivated and achieve your targets!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Links -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h4 class="text-lg font-semibold text-gray-900">Quick Links</h4>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
routerLink="/"
|
||||||
|
class="text-gray-600 hover:text-blue-600 transition-colors duration-200 flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span>📊</span>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
routerLink="/sport"
|
||||||
|
class="text-gray-600 hover:text-blue-600 transition-colors duration-200 flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span>🏃</span>
|
||||||
|
<span>Sport Goals</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
routerLink="/gaming"
|
||||||
|
class="text-gray-600 hover:text-purple-600 transition-colors duration-200 flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span>🎮</span>
|
||||||
|
<span>Gaming Goals</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Section -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h4 class="text-lg font-semibold text-gray-900">About</h4>
|
||||||
|
<ul class="space-y-2 text-sm text-gray-600">
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<span>📅</span>
|
||||||
|
<span>Tracking goals for 2026</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<span>⚡</span>
|
||||||
|
<span>Built with Angular 19</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<span>🔗</span>
|
||||||
|
<span>Integrated with Strava, Riot Games, Faceit</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Bar -->
|
||||||
|
<div class="mt-8 pt-8 border-t border-gray-200">
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
© {{ currentYear }} 2026 Goals Tracker. All rights reserved.
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-4 text-sm text-gray-600">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span>✨</span>
|
||||||
|
<span>Stay motivated!</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
`,
|
||||||
|
styles: [],
|
||||||
|
})
|
||||||
|
export class FooterComponent {
|
||||||
|
readonly currentYear = new Date().getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
ElementRef,
|
||||||
|
ViewChild,
|
||||||
|
AfterViewInit,
|
||||||
|
Input,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-loading-screen',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-br from-gray-50 via-blue-50 to-purple-50 transition-opacity duration-500"
|
||||||
|
[class.opacity-0]="getFadeOut()"
|
||||||
|
[class.pointer-events-none]="getFadeOut()"
|
||||||
|
>
|
||||||
|
<canvas #canvas class="absolute inset-0 w-full h-full"></canvas>
|
||||||
|
<div class="relative z-10 text-center">
|
||||||
|
<h1 class="text-5xl font-bold text-gray-900 mb-4 flex items-center justify-center gap-3">
|
||||||
|
<span class="text-6xl">🎯</span>
|
||||||
|
<span>2026 Goals Tracker</span>
|
||||||
|
</h1>
|
||||||
|
<div class="w-64 h-2 bg-white/30 rounded-full overflow-hidden mx-auto mb-4">
|
||||||
|
<div
|
||||||
|
class="h-full bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 rounded-full transition-all duration-300 ease-out"
|
||||||
|
[style.width.%]="getProgress()"
|
||||||
|
>
|
||||||
|
<div class="h-full w-full animate-shimmer"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 text-lg">{{ getLoadingText() }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-shimmer {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(255, 255, 255, 0.4) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class LoadingScreenComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
@Input() progress = signal(0);
|
||||||
|
@Input() loadingText = signal('Loading...');
|
||||||
|
@Input() fadeOut = signal(false);
|
||||||
|
|
||||||
|
private scene!: THREE.Scene;
|
||||||
|
private camera!: THREE.PerspectiveCamera;
|
||||||
|
private renderer!: THREE.WebGLRenderer;
|
||||||
|
private particles!: THREE.Points;
|
||||||
|
private animationId: number | null = null;
|
||||||
|
private particleCount = 300;
|
||||||
|
private particleVelocities: Float32Array | null = null;
|
||||||
|
|
||||||
|
ngOnInit(): void {}
|
||||||
|
|
||||||
|
getProgress(): number {
|
||||||
|
return typeof this.progress === 'function' ? this.progress() : this.progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLoadingText(): string {
|
||||||
|
return typeof this.loadingText === 'function' ? this.loadingText() : this.loadingText;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFadeOut(): boolean {
|
||||||
|
return typeof this.fadeOut === 'function' ? this.fadeOut() : this.fadeOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.initThree();
|
||||||
|
this.createParticles();
|
||||||
|
this.animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.animationId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
}
|
||||||
|
if (this.renderer) {
|
||||||
|
this.renderer.dispose();
|
||||||
|
}
|
||||||
|
if (this.particles) {
|
||||||
|
this.particles.geometry.dispose();
|
||||||
|
(this.particles.material as THREE.PointsMaterial).dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private initThree(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
|
||||||
|
// Scene
|
||||||
|
this.scene = new THREE.Scene();
|
||||||
|
|
||||||
|
// Camera
|
||||||
|
this.camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
||||||
|
this.camera.position.z = 5;
|
||||||
|
|
||||||
|
// Renderer
|
||||||
|
this.renderer = new THREE.WebGLRenderer({
|
||||||
|
canvas,
|
||||||
|
alpha: true,
|
||||||
|
antialias: true,
|
||||||
|
});
|
||||||
|
this.renderer.setSize(width, height);
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
this.renderer.setClearColor(0x000000, 0);
|
||||||
|
|
||||||
|
// Handle resize
|
||||||
|
window.addEventListener('resize', () => this.onWindowResize());
|
||||||
|
}
|
||||||
|
|
||||||
|
private createParticles(): void {
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
const positions = new Float32Array(this.particleCount * 3);
|
||||||
|
const colors = new Float32Array(this.particleCount * 3);
|
||||||
|
this.particleVelocities = new Float32Array(this.particleCount * 3);
|
||||||
|
|
||||||
|
const colorPalette = [
|
||||||
|
new THREE.Color(0x3b82f6), // Blue
|
||||||
|
new THREE.Color(0x8b5cf6), // Purple
|
||||||
|
new THREE.Color(0xec4899), // Pink
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < this.particleCount; i++) {
|
||||||
|
const i3 = i * 3;
|
||||||
|
|
||||||
|
// Position
|
||||||
|
positions[i3] = (Math.random() - 0.5) * 10;
|
||||||
|
positions[i3 + 1] = (Math.random() - 0.5) * 10;
|
||||||
|
positions[i3 + 2] = (Math.random() - 0.5) * 10;
|
||||||
|
|
||||||
|
// Color
|
||||||
|
const color = colorPalette[Math.floor(Math.random() * colorPalette.length)];
|
||||||
|
colors[i3] = color.r;
|
||||||
|
colors[i3 + 1] = color.g;
|
||||||
|
colors[i3 + 2] = color.b;
|
||||||
|
|
||||||
|
// Velocity
|
||||||
|
this.particleVelocities![i3] = (Math.random() - 0.5) * 0.02;
|
||||||
|
this.particleVelocities![i3 + 1] = (Math.random() - 0.5) * 0.02;
|
||||||
|
this.particleVelocities![i3 + 2] = (Math.random() - 0.5) * 0.02;
|
||||||
|
}
|
||||||
|
|
||||||
|
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||||
|
|
||||||
|
const material = new THREE.PointsMaterial({
|
||||||
|
size: 0.1,
|
||||||
|
vertexColors: true,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.8,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.particles = new THREE.Points(geometry, material);
|
||||||
|
this.scene.add(this.particles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private animate = (): void => {
|
||||||
|
this.animationId = requestAnimationFrame(this.animate);
|
||||||
|
|
||||||
|
if (!this.particles || !this.particleVelocities) return;
|
||||||
|
|
||||||
|
const positions = this.particles.geometry.attributes['position'].array as Float32Array;
|
||||||
|
const velocities = this.particleVelocities;
|
||||||
|
|
||||||
|
// Update particle positions
|
||||||
|
for (let i = 0; i < positions.length; i += 3) {
|
||||||
|
positions[i] += velocities[i];
|
||||||
|
positions[i + 1] += velocities[i + 1];
|
||||||
|
positions[i + 2] += velocities[i + 2];
|
||||||
|
|
||||||
|
// Wrap around boundaries
|
||||||
|
if (Math.abs(positions[i]) > 5) velocities[i] *= -1;
|
||||||
|
if (Math.abs(positions[i + 1]) > 5) velocities[i + 1] *= -1;
|
||||||
|
if (Math.abs(positions[i + 2]) > 5) velocities[i + 2] *= -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate particles around center
|
||||||
|
this.particles.rotation.y += 0.001;
|
||||||
|
this.particles.rotation.x += 0.0005;
|
||||||
|
|
||||||
|
// Update geometry
|
||||||
|
this.particles.geometry.attributes['position'].needsUpdate = true;
|
||||||
|
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onWindowResize(): void {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
|
||||||
|
this.camera.aspect = width / height;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
this.renderer.setSize(width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
ElementRef,
|
||||||
|
ViewChild,
|
||||||
|
AfterViewInit,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
interface Block {
|
||||||
|
mesh: THREE.Mesh;
|
||||||
|
alive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-magic-ball-game',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="magic-ball-game-container">
|
||||||
|
<canvas #canvas class="absolute inset-0 w-full h-full"></canvas>
|
||||||
|
<div class="absolute top-4 left-4 bg-black/70 text-white px-4 py-2 rounded-lg text-sm font-bold z-10">
|
||||||
|
Score: {{ score }} | Lives: {{ lives }}
|
||||||
|
</div>
|
||||||
|
<div class="absolute top-4 right-4 bg-black/70 text-white px-4 py-2 rounded-lg text-sm z-10">
|
||||||
|
Move mouse up/down to control paddle
|
||||||
|
</div>
|
||||||
|
@if (gameOver) {
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center bg-black/70 z-20">
|
||||||
|
<div class="text-center text-white">
|
||||||
|
<h2 class="text-4xl font-bold mb-4">Game Over!</h2>
|
||||||
|
<p class="text-2xl mb-4">Final Score: {{ score }}</p>
|
||||||
|
<button
|
||||||
|
(click)="restart()"
|
||||||
|
class="px-6 py-3 bg-purple-600 text-white rounded-lg font-bold hover:bg-purple-700 transition-all"
|
||||||
|
>
|
||||||
|
Play Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.magic-ball-game-container {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class MagicBallGameComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
|
private scene!: THREE.Scene;
|
||||||
|
private camera!: THREE.OrthographicCamera;
|
||||||
|
private renderer!: THREE.WebGLRenderer;
|
||||||
|
private animationId: number | null = null;
|
||||||
|
private ball!: THREE.Mesh;
|
||||||
|
private paddle!: THREE.Mesh;
|
||||||
|
private blocks: Block[] = [];
|
||||||
|
private ballVelocity = new THREE.Vector2(0.15, 0.15);
|
||||||
|
private mouseY = 0;
|
||||||
|
private aspect = 1;
|
||||||
|
score = 0;
|
||||||
|
lives = 3;
|
||||||
|
gameOver = false;
|
||||||
|
|
||||||
|
ngOnInit(): void {}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.initThree();
|
||||||
|
this.createScene();
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.animationId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
}
|
||||||
|
if (this.renderer) {
|
||||||
|
this.renderer.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private initThree(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
this.aspect = width / height;
|
||||||
|
|
||||||
|
this.scene = new THREE.Scene();
|
||||||
|
this.scene.background = null;
|
||||||
|
|
||||||
|
// Orthographic camera for 2D
|
||||||
|
const viewSize = 20;
|
||||||
|
this.camera = new THREE.OrthographicCamera(
|
||||||
|
-viewSize * this.aspect,
|
||||||
|
viewSize * this.aspect,
|
||||||
|
viewSize,
|
||||||
|
-viewSize,
|
||||||
|
0.1,
|
||||||
|
1000
|
||||||
|
);
|
||||||
|
this.camera.position.z = 10;
|
||||||
|
|
||||||
|
this.renderer = new THREE.WebGLRenderer({
|
||||||
|
canvas,
|
||||||
|
alpha: true,
|
||||||
|
antialias: true,
|
||||||
|
});
|
||||||
|
this.renderer.setSize(width, height);
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => this.onWindowResize());
|
||||||
|
}
|
||||||
|
|
||||||
|
private createScene(): void {
|
||||||
|
// Ball (2D circle)
|
||||||
|
const ballGeometry = new THREE.CircleGeometry(0.4, 32);
|
||||||
|
const ballMaterial = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x8b5cf6,
|
||||||
|
});
|
||||||
|
this.ball = new THREE.Mesh(ballGeometry, ballMaterial);
|
||||||
|
this.ball.position.set(-15, 0, 0);
|
||||||
|
this.scene.add(this.ball);
|
||||||
|
|
||||||
|
// Paddle (on the left, vertical rectangle)
|
||||||
|
const paddleGeometry = new THREE.PlaneGeometry(0.6, 4);
|
||||||
|
const paddleMaterial = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xec4899,
|
||||||
|
});
|
||||||
|
this.paddle = new THREE.Mesh(paddleGeometry, paddleMaterial);
|
||||||
|
this.paddle.position.set(-17, 0, 0);
|
||||||
|
this.scene.add(this.paddle);
|
||||||
|
|
||||||
|
// Create blocks (on the right side, 2D)
|
||||||
|
this.createBlocks();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createBlocks(): void {
|
||||||
|
const rows = 8;
|
||||||
|
const cols = 4;
|
||||||
|
const blockWidth = 2;
|
||||||
|
const blockHeight = 1.5;
|
||||||
|
const spacing = 0.3;
|
||||||
|
const startX = 12;
|
||||||
|
const startY = (rows * (blockHeight + spacing)) / 2 - blockHeight / 2;
|
||||||
|
|
||||||
|
const colors = [0xff0000, 0xff8800, 0xffff00, 0x00ff00, 0x0088ff, 0x0000ff, 0x8800ff, 0xff00ff];
|
||||||
|
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
const blockGeometry = new THREE.PlaneGeometry(blockWidth, blockHeight);
|
||||||
|
const blockMaterial = new THREE.MeshBasicMaterial({
|
||||||
|
color: colors[row % colors.length],
|
||||||
|
});
|
||||||
|
const blockMesh = new THREE.Mesh(blockGeometry, blockMaterial);
|
||||||
|
blockMesh.position.set(
|
||||||
|
startX + col * (blockWidth + spacing),
|
||||||
|
startY - row * (blockHeight + spacing),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
this.scene.add(blockMesh);
|
||||||
|
this.blocks.push({ mesh: blockMesh, alive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventListeners(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
|
||||||
|
canvas.addEventListener('mousemove', (e) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const normalizedY = ((e.clientY - rect.top) / rect.height) * 2 - 1;
|
||||||
|
const viewSize = 20;
|
||||||
|
this.mouseY = -normalizedY * viewSize; // Convert to world space
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePaddle(): void {
|
||||||
|
this.paddle.position.y = this.mouseY;
|
||||||
|
// Keep paddle in bounds
|
||||||
|
const viewSize = 20;
|
||||||
|
this.paddle.position.y = Math.max(-viewSize + 2, Math.min(viewSize - 2, this.paddle.position.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateBall(): void {
|
||||||
|
if (this.gameOver) return;
|
||||||
|
|
||||||
|
this.ball.position.x += this.ballVelocity.x;
|
||||||
|
this.ball.position.y += this.ballVelocity.y;
|
||||||
|
|
||||||
|
const viewSize = 20;
|
||||||
|
|
||||||
|
// Bounce off top/bottom walls
|
||||||
|
if (Math.abs(this.ball.position.y) > viewSize - 0.5) {
|
||||||
|
this.ballVelocity.y *= -1;
|
||||||
|
this.ball.position.y = Math.max(-viewSize + 0.5, Math.min(viewSize - 0.5, this.ball.position.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounce off right wall
|
||||||
|
if (this.ball.position.x > viewSize * this.aspect - 0.5) {
|
||||||
|
this.ballVelocity.x *= -1;
|
||||||
|
this.ball.position.x = viewSize * this.aspect - 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paddle collision (left side)
|
||||||
|
const paddleBox = new THREE.Box2(
|
||||||
|
new THREE.Vector2(this.paddle.position.x - 0.3, this.paddle.position.y - 2),
|
||||||
|
new THREE.Vector2(this.paddle.position.x + 0.3, this.paddle.position.y + 2)
|
||||||
|
);
|
||||||
|
const ballPos = new THREE.Vector2(this.ball.position.x, this.ball.position.y);
|
||||||
|
const ballRadius = 0.4;
|
||||||
|
|
||||||
|
if (
|
||||||
|
ballPos.x - ballRadius < paddleBox.max.x &&
|
||||||
|
ballPos.x + ballRadius > paddleBox.min.x &&
|
||||||
|
ballPos.y - ballRadius < paddleBox.max.y &&
|
||||||
|
ballPos.y + ballRadius > paddleBox.min.y &&
|
||||||
|
this.ballVelocity.x < 0
|
||||||
|
) {
|
||||||
|
this.ballVelocity.x *= -1;
|
||||||
|
// Add some angle based on where ball hits paddle
|
||||||
|
const hitY = (this.ball.position.y - this.paddle.position.y) / 2;
|
||||||
|
this.ballVelocity.y += hitY * 0.05;
|
||||||
|
this.ball.position.x = -16.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block collisions
|
||||||
|
for (const block of this.blocks) {
|
||||||
|
if (!block.alive) continue;
|
||||||
|
|
||||||
|
const blockBox = new THREE.Box2(
|
||||||
|
new THREE.Vector2(block.mesh.position.x - 1, block.mesh.position.y - 0.75),
|
||||||
|
new THREE.Vector2(block.mesh.position.x + 1, block.mesh.position.y + 0.75)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
ballPos.x - ballRadius < blockBox.max.x &&
|
||||||
|
ballPos.x + ballRadius > blockBox.min.x &&
|
||||||
|
ballPos.y - ballRadius < blockBox.max.y &&
|
||||||
|
ballPos.y + ballRadius > blockBox.min.y
|
||||||
|
) {
|
||||||
|
block.alive = false;
|
||||||
|
block.mesh.visible = false;
|
||||||
|
this.score += 10;
|
||||||
|
|
||||||
|
// Determine bounce direction
|
||||||
|
const blockCenter = new THREE.Vector2(block.mesh.position.x, block.mesh.position.y);
|
||||||
|
const dx = ballPos.x - blockCenter.x;
|
||||||
|
const dy = ballPos.y - blockCenter.y;
|
||||||
|
|
||||||
|
if (Math.abs(dx) > Math.abs(dy)) {
|
||||||
|
this.ballVelocity.x *= -1;
|
||||||
|
} else {
|
||||||
|
this.ballVelocity.y *= -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ball went past paddle (left side)
|
||||||
|
if (this.ball.position.x < -viewSize * this.aspect) {
|
||||||
|
this.lives--;
|
||||||
|
if (this.lives <= 0) {
|
||||||
|
this.gameOver = true;
|
||||||
|
} else {
|
||||||
|
// Reset ball
|
||||||
|
this.ball.position.set(-15, 0, 0);
|
||||||
|
this.ballVelocity.set(0.15, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all blocks destroyed
|
||||||
|
if (this.blocks.every((b) => !b.alive)) {
|
||||||
|
// Reset blocks
|
||||||
|
this.blocks.forEach((block) => {
|
||||||
|
block.alive = true;
|
||||||
|
block.mesh.visible = true;
|
||||||
|
});
|
||||||
|
this.ballVelocity.multiplyScalar(1.1); // Speed up
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private animate = (): void => {
|
||||||
|
this.animationId = requestAnimationFrame(this.animate);
|
||||||
|
this.updatePaddle();
|
||||||
|
this.updateBall();
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onWindowResize(): void {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
this.aspect = width / height;
|
||||||
|
|
||||||
|
const viewSize = 20;
|
||||||
|
this.camera.left = -viewSize * this.aspect;
|
||||||
|
this.camera.right = viewSize * this.aspect;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
this.renderer.setSize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
restart(): void {
|
||||||
|
this.score = 0;
|
||||||
|
this.lives = 3;
|
||||||
|
this.gameOver = false;
|
||||||
|
this.ball.position.set(-15, 0, 0);
|
||||||
|
this.ballVelocity.set(0.15, 0.15);
|
||||||
|
this.blocks.forEach((block) => {
|
||||||
|
block.alive = true;
|
||||||
|
block.mesh.visible = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Match } from '../../../core/models/gaming-goal.model';
|
||||||
|
import { formatDate } from '../../utils/date.utils';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-match-history',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Recent Matches
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
*ngFor="let match of matches"
|
||||||
|
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full"
|
||||||
|
[class.bg-green-500]="match.result === 'win'"
|
||||||
|
[class.bg-red-500]="match.result === 'loss'"
|
||||||
|
[class.bg-gray-400]="match.result === 'draw'"
|
||||||
|
></span>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900">
|
||||||
|
{{ formatDate(match.date) }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500" *ngIf="match.rank">
|
||||||
|
{{ match.rank }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<span
|
||||||
|
class="text-sm font-semibold"
|
||||||
|
[class.text-green-600]="match.result === 'win'"
|
||||||
|
[class.text-red-600]="match.result === 'loss'"
|
||||||
|
[class.text-gray-600]="match.result === 'draw'"
|
||||||
|
>
|
||||||
|
{{ match.result.toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
<p class="text-xs text-gray-500" *ngIf="match.score">
|
||||||
|
Score: {{ match.score }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p *ngIf="matches.length === 0" class="text-sm text-gray-500">
|
||||||
|
No recent matches available
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [],
|
||||||
|
})
|
||||||
|
export class MatchHistoryComponent {
|
||||||
|
@Input() matches: Match[] = [];
|
||||||
|
|
||||||
|
readonly formatDate = formatDate;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterLink, RouterLinkActive, Router } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-navbar',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink, RouterLinkActive],
|
||||||
|
template: `
|
||||||
|
<nav class="bg-white/80 backdrop-blur-md shadow-lg border-b-2 border-blue-200 sticky top-0 z-20">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex items-center justify-between h-16">
|
||||||
|
<!-- Logo/Brand -->
|
||||||
|
<div class="flex items-center">
|
||||||
|
<a
|
||||||
|
routerLink="/"
|
||||||
|
class="flex items-center gap-3 text-2xl font-bold text-gray-900 hover:text-blue-600 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<span class="text-3xl">🎯</span>
|
||||||
|
<span>2026 Goals Tracker</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Links -->
|
||||||
|
<div class="hidden md:flex items-center gap-1">
|
||||||
|
<a
|
||||||
|
routerLink="/"
|
||||||
|
routerLinkActive="bg-blue-500 text-white"
|
||||||
|
[routerLinkActiveOptions]="{ exact: true }"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-all duration-200 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>📊</span>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
routerLink="/sport"
|
||||||
|
routerLinkActive="bg-blue-500 text-white"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-all duration-200 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>🏃</span>
|
||||||
|
<span>Sport</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
routerLink="/gaming"
|
||||||
|
routerLinkActive="bg-purple-500 text-white"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium text-gray-700 hover:bg-purple-50 hover:text-purple-600 transition-all duration-200 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>🎮</span>
|
||||||
|
<span>Gaming</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Menu Button -->
|
||||||
|
<button
|
||||||
|
(click)="toggleMobileMenu()"
|
||||||
|
class="md:hidden p-2 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors duration-200"
|
||||||
|
[attr.aria-label]="mobileMenuOpen ? 'Close menu' : 'Open menu'"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
[class.hidden]="mobileMenuOpen"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
[class.hidden]="!mobileMenuOpen"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Menu -->
|
||||||
|
<div
|
||||||
|
class="md:hidden pb-4"
|
||||||
|
[class.hidden]="!mobileMenuOpen"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2 mt-2">
|
||||||
|
<a
|
||||||
|
routerLink="/"
|
||||||
|
routerLinkActive="bg-blue-500 text-white"
|
||||||
|
[routerLinkActiveOptions]="{ exact: true }"
|
||||||
|
(click)="closeMobileMenu()"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-all duration-200 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>📊</span>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
routerLink="/sport"
|
||||||
|
routerLinkActive="bg-blue-500 text-white"
|
||||||
|
(click)="closeMobileMenu()"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-all duration-200 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>🏃</span>
|
||||||
|
<span>Sport</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
routerLink="/gaming"
|
||||||
|
routerLinkActive="bg-purple-500 text-white"
|
||||||
|
(click)="closeMobileMenu()"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium text-gray-700 hover:bg-purple-50 hover:text-purple-600 transition-all duration-200 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>🎮</span>
|
||||||
|
<span>Gaming</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
a[routerLinkActive] {
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class NavbarComponent implements OnInit {
|
||||||
|
mobileMenuOpen = false;
|
||||||
|
|
||||||
|
constructor(private readonly router: Router) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
// Close mobile menu on route change
|
||||||
|
this.router.events.subscribe(() => {
|
||||||
|
this.closeMobileMenu();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMobileMenu(): void {
|
||||||
|
this.mobileMenuOpen = !this.mobileMenuOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeMobileMenu(): void {
|
||||||
|
this.mobileMenuOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
ElementRef,
|
||||||
|
ViewChild,
|
||||||
|
AfterViewInit,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-particle-background',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<canvas #canvas class="fixed inset-0 w-full h-full pointer-events-none"></canvas>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
z-index: 0;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ParticleBackgroundComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
|
private scene!: THREE.Scene;
|
||||||
|
private camera!: THREE.PerspectiveCamera;
|
||||||
|
private renderer!: THREE.WebGLRenderer;
|
||||||
|
private particles!: THREE.Points;
|
||||||
|
private animationId: number | null = null;
|
||||||
|
private particleCount = 500;
|
||||||
|
private mouseX = 0;
|
||||||
|
private mouseY = 0;
|
||||||
|
private mouseWorldX = 0;
|
||||||
|
private mouseWorldY = 0;
|
||||||
|
private particleVelocities: Float32Array | null = null;
|
||||||
|
private mouseDownTime = 0;
|
||||||
|
private isMouseDown = false;
|
||||||
|
private clickWorldX = 0;
|
||||||
|
private clickWorldY = 0;
|
||||||
|
private repulsionWaves: Array<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
radius: number;
|
||||||
|
maxRadius: number;
|
||||||
|
opacity: number;
|
||||||
|
time: number;
|
||||||
|
}> = [];
|
||||||
|
private waveObjects: THREE.Mesh[] = [];
|
||||||
|
|
||||||
|
ngOnInit(): void {}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.initThree();
|
||||||
|
this.createParticles();
|
||||||
|
this.animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.animationId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
}
|
||||||
|
// Clean up wave objects
|
||||||
|
this.waveObjects.forEach((wave) => {
|
||||||
|
this.scene.remove(wave);
|
||||||
|
wave.geometry.dispose();
|
||||||
|
(wave.material as THREE.Material).dispose();
|
||||||
|
});
|
||||||
|
this.waveObjects = [];
|
||||||
|
if (this.renderer) {
|
||||||
|
this.renderer.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private initThree(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
|
||||||
|
// Scene
|
||||||
|
this.scene = new THREE.Scene();
|
||||||
|
|
||||||
|
// Camera
|
||||||
|
this.camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
||||||
|
this.camera.position.z = 5;
|
||||||
|
|
||||||
|
// Renderer
|
||||||
|
this.renderer = new THREE.WebGLRenderer({
|
||||||
|
canvas: canvas,
|
||||||
|
alpha: true,
|
||||||
|
antialias: true,
|
||||||
|
});
|
||||||
|
this.renderer.setSize(width, height);
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => this.onWindowResize());
|
||||||
|
this.addMouseInteraction();
|
||||||
|
}
|
||||||
|
|
||||||
|
private screenToWorld(x: number, y: number): { x: number; y: number } {
|
||||||
|
// Convert screen coordinates to normalized device coordinates
|
||||||
|
const mouseX = (x / window.innerWidth) * 2 - 1;
|
||||||
|
const mouseY = -(y / window.innerHeight) * 2 + 1;
|
||||||
|
|
||||||
|
// Create a vector in normalized device coordinates
|
||||||
|
const vector = new THREE.Vector3(mouseX, mouseY, 0.5);
|
||||||
|
|
||||||
|
// Unproject to world coordinates
|
||||||
|
vector.unproject(this.camera);
|
||||||
|
|
||||||
|
// Calculate direction from camera
|
||||||
|
const dir = vector.sub(this.camera.position).normalize();
|
||||||
|
|
||||||
|
// Find intersection with z=0 plane (where particles are)
|
||||||
|
const distance = -this.camera.position.z / dir.z;
|
||||||
|
const pos = this.camera.position.clone().add(dir.multiplyScalar(distance));
|
||||||
|
|
||||||
|
return { x: pos.x, y: pos.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
private addMouseInteraction(): void {
|
||||||
|
let lastMouseEvent: MouseEvent | null = null;
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', (event) => {
|
||||||
|
lastMouseEvent = event;
|
||||||
|
this.mouseX = (event.clientX / window.innerWidth) * 2 - 1;
|
||||||
|
this.mouseY = -(event.clientY / window.innerHeight) * 2 + 1;
|
||||||
|
const worldPos = this.screenToWorld(event.clientX, event.clientY);
|
||||||
|
this.mouseWorldX = worldPos.x;
|
||||||
|
this.mouseWorldY = worldPos.y;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('mousedown', (event) => {
|
||||||
|
this.isMouseDown = true;
|
||||||
|
this.mouseDownTime = Date.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('mouseup', (event) => {
|
||||||
|
if (this.isMouseDown) {
|
||||||
|
// Calculate world position from event coordinates directly
|
||||||
|
const worldPos = this.screenToWorld(event.clientX, event.clientY);
|
||||||
|
this.clickWorldX = worldPos.x;
|
||||||
|
this.clickWorldY = worldPos.y;
|
||||||
|
const holdDuration = Date.now() - this.mouseDownTime;
|
||||||
|
this.applyRepulsion(holdDuration);
|
||||||
|
this.isMouseDown = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle mouse leaving window
|
||||||
|
window.addEventListener('mouseleave', () => {
|
||||||
|
if (this.isMouseDown && lastMouseEvent) {
|
||||||
|
// Use last known mouse position with proper conversion
|
||||||
|
const worldPos = this.screenToWorld(lastMouseEvent.clientX, lastMouseEvent.clientY);
|
||||||
|
this.clickWorldX = worldPos.x;
|
||||||
|
this.clickWorldY = worldPos.y;
|
||||||
|
const holdDuration = Date.now() - this.mouseDownTime;
|
||||||
|
this.applyRepulsion(holdDuration);
|
||||||
|
this.isMouseDown = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyRepulsion(holdDuration: number): void {
|
||||||
|
if (!this.particleVelocities) return;
|
||||||
|
|
||||||
|
const positions = this.particles.geometry.attributes['position'].array as Float32Array;
|
||||||
|
const velocities = this.particleVelocities;
|
||||||
|
|
||||||
|
// Calculate power based on hold duration with limits
|
||||||
|
const minHoldTime = 50; // Minimum 50ms to register
|
||||||
|
const maxHoldTime = 1000; // Maximum 1 second for full power
|
||||||
|
const clampedHold = Math.max(minHoldTime, Math.min(holdDuration, maxHoldTime));
|
||||||
|
const normalizedHold = (clampedHold - minHoldTime) / (maxHoldTime - minHoldTime);
|
||||||
|
|
||||||
|
// Strength limits
|
||||||
|
const baseStrength = 0.15;
|
||||||
|
const maxStrength = 0.8;
|
||||||
|
const repulsionStrength = Math.min(
|
||||||
|
baseStrength + (maxStrength - baseStrength) * normalizedHold,
|
||||||
|
maxStrength
|
||||||
|
);
|
||||||
|
|
||||||
|
// Radius limits
|
||||||
|
const minRadius = 2.5;
|
||||||
|
const maxRadius = 5;
|
||||||
|
const repulsionRadius = Math.min(
|
||||||
|
minRadius + (maxRadius - minRadius) * normalizedHold,
|
||||||
|
maxRadius
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add visual repulsion wave - scale based on hold duration
|
||||||
|
const waveOpacity = 0.3 + normalizedHold * 0.3; // 0.3 to 0.6 based on hold
|
||||||
|
const waveMaxRadius = repulsionRadius * (1.2 + normalizedHold * 0.5); // 1.2x to 1.7x based on hold
|
||||||
|
|
||||||
|
this.repulsionWaves.push({
|
||||||
|
x: this.clickWorldX,
|
||||||
|
y: this.clickWorldY,
|
||||||
|
radius: 0,
|
||||||
|
maxRadius: waveMaxRadius,
|
||||||
|
opacity: waveOpacity,
|
||||||
|
time: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < positions.length; i += 3) {
|
||||||
|
const x = positions[i];
|
||||||
|
const y = positions[i + 1];
|
||||||
|
const z = positions[i + 2];
|
||||||
|
|
||||||
|
// Calculate distance from click point
|
||||||
|
const dx = x - this.clickWorldX;
|
||||||
|
const dy = y - this.clickWorldY;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
// Apply repulsion if within radius
|
||||||
|
if (distance < repulsionRadius && distance > 0.1) {
|
||||||
|
const normalizedDx = dx / distance;
|
||||||
|
const normalizedDy = dy / distance;
|
||||||
|
const force = (1 - distance / repulsionRadius) * repulsionStrength;
|
||||||
|
|
||||||
|
// Add force to velocity (physics-based)
|
||||||
|
velocities[i] += normalizedDx * force;
|
||||||
|
velocities[i + 1] += normalizedDy * force;
|
||||||
|
velocities[i + 2] += (Math.random() - 0.5) * force * 0.4; // Z-axis movement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createParticles(): void {
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
const positions = new Float32Array(this.particleCount * 3);
|
||||||
|
const colors = new Float32Array(this.particleCount * 3);
|
||||||
|
|
||||||
|
const color1 = new THREE.Color(0x3b82f6); // Blue
|
||||||
|
const color2 = new THREE.Color(0x8b5cf6); // Purple
|
||||||
|
const color3 = new THREE.Color(0xec4899); // Pink
|
||||||
|
|
||||||
|
for (let i = 0; i < this.particleCount; i++) {
|
||||||
|
const i3 = i * 3;
|
||||||
|
|
||||||
|
// Position
|
||||||
|
positions[i3] = (Math.random() - 0.5) * 20;
|
||||||
|
positions[i3 + 1] = (Math.random() - 0.5) * 20;
|
||||||
|
positions[i3 + 2] = (Math.random() - 0.5) * 20;
|
||||||
|
|
||||||
|
// Color
|
||||||
|
const colorChoice = Math.random();
|
||||||
|
let color: THREE.Color;
|
||||||
|
if (colorChoice < 0.33) {
|
||||||
|
color = color1;
|
||||||
|
} else if (colorChoice < 0.66) {
|
||||||
|
color = color2;
|
||||||
|
} else {
|
||||||
|
color = color3;
|
||||||
|
}
|
||||||
|
|
||||||
|
colors[i3] = color.r;
|
||||||
|
colors[i3 + 1] = color.g;
|
||||||
|
colors[i3 + 2] = color.b;
|
||||||
|
}
|
||||||
|
|
||||||
|
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||||
|
|
||||||
|
// Initialize velocities for physics
|
||||||
|
this.particleVelocities = new Float32Array(this.particleCount * 3);
|
||||||
|
for (let i = 0; i < this.particleVelocities.length; i++) {
|
||||||
|
this.particleVelocities[i] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const material = new THREE.PointsMaterial({
|
||||||
|
size: 0.08,
|
||||||
|
vertexColors: true,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.3,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.particles = new THREE.Points(geometry, material);
|
||||||
|
this.scene.add(this.particles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private animate = (): void => {
|
||||||
|
this.animationId = requestAnimationFrame(this.animate);
|
||||||
|
|
||||||
|
if (this.particles) {
|
||||||
|
// Much slower rotation
|
||||||
|
this.particles.rotation.x += 0.0001;
|
||||||
|
this.particles.rotation.y += 0.0002;
|
||||||
|
|
||||||
|
// Mouse interaction - subtle rotation
|
||||||
|
this.particles.rotation.x += this.mouseY * 0.0001;
|
||||||
|
this.particles.rotation.y += this.mouseX * 0.00015;
|
||||||
|
|
||||||
|
// Physics-based movement with ease-out
|
||||||
|
const positions = this.particles.geometry.attributes['position'].array as Float32Array;
|
||||||
|
const velocities = this.particleVelocities!;
|
||||||
|
|
||||||
|
const damping = 0.92; // Ease-out damping (lower = faster decay)
|
||||||
|
const maxVelocity = 0.5; // Increased for stronger repulsion effects
|
||||||
|
|
||||||
|
// Continuous mouse repulsion (weaker, always active)
|
||||||
|
const mouseRepulsionRadius = 2;
|
||||||
|
const mouseRepulsionStrength = 0.0003; // Much weaker than click repulsion
|
||||||
|
|
||||||
|
for (let i = 0; i < positions.length; i += 3) {
|
||||||
|
const x = positions[i];
|
||||||
|
const y = positions[i + 1];
|
||||||
|
const z = positions[i + 2];
|
||||||
|
|
||||||
|
// Calculate distance from mouse
|
||||||
|
const dx = x - this.mouseWorldX;
|
||||||
|
const dy = y - this.mouseWorldY;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
// Apply continuous mouse repulsion if within radius
|
||||||
|
if (distance < mouseRepulsionRadius && distance > 0.1) {
|
||||||
|
const normalizedDx = dx / distance;
|
||||||
|
const normalizedDy = dy / distance;
|
||||||
|
const force = (1 - distance / mouseRepulsionRadius) * mouseRepulsionStrength;
|
||||||
|
|
||||||
|
// Add weak force to velocity
|
||||||
|
velocities[i] += normalizedDx * force;
|
||||||
|
velocities[i + 1] += normalizedDy * force;
|
||||||
|
velocities[i + 2] += (Math.random() - 0.5) * force * 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply damping (ease-out effect)
|
||||||
|
velocities[i] *= damping;
|
||||||
|
velocities[i + 1] *= damping;
|
||||||
|
velocities[i + 2] *= damping;
|
||||||
|
|
||||||
|
// Limit max velocity
|
||||||
|
const vx = Math.max(-maxVelocity, Math.min(maxVelocity, velocities[i]));
|
||||||
|
const vy = Math.max(-maxVelocity, Math.min(maxVelocity, velocities[i + 1]));
|
||||||
|
const vz = Math.max(-maxVelocity, Math.min(maxVelocity, velocities[i + 2]));
|
||||||
|
|
||||||
|
velocities[i] = vx;
|
||||||
|
velocities[i + 1] = vy;
|
||||||
|
velocities[i + 2] = vz;
|
||||||
|
|
||||||
|
// Update position based on velocity
|
||||||
|
positions[i] += velocities[i];
|
||||||
|
positions[i + 1] += velocities[i + 1];
|
||||||
|
positions[i + 2] += velocities[i + 2];
|
||||||
|
|
||||||
|
// Slower vertical drift
|
||||||
|
positions[i + 1] += 0.001;
|
||||||
|
|
||||||
|
// Wrap around boundaries
|
||||||
|
if (positions[i] > 10) positions[i] = -10;
|
||||||
|
if (positions[i] < -10) positions[i] = 10;
|
||||||
|
if (positions[i + 1] > 10) positions[i + 1] = -10;
|
||||||
|
if (positions[i + 1] < -10) positions[i + 1] = 10;
|
||||||
|
if (positions[i + 2] > 10) positions[i + 2] = -10;
|
||||||
|
if (positions[i + 2] < -10) positions[i + 2] = 10;
|
||||||
|
}
|
||||||
|
this.particles.geometry.attributes['position'].needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update and render repulsion waves
|
||||||
|
this.updateRepulsionWaves();
|
||||||
|
|
||||||
|
// Camera follows mouse slightly
|
||||||
|
this.camera.position.x += (this.mouseX * 0.3 - this.camera.position.x) * 0.03;
|
||||||
|
this.camera.position.y += (this.mouseY * 0.3 - this.camera.position.y) * 0.03;
|
||||||
|
this.camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
};
|
||||||
|
|
||||||
|
private updateRepulsionWaves(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const waveDuration = 1500; // Longer, softer animation duration
|
||||||
|
|
||||||
|
// Update existing waves
|
||||||
|
for (let i = this.repulsionWaves.length - 1; i >= 0; i--) {
|
||||||
|
const wave = this.repulsionWaves[i];
|
||||||
|
const elapsed = now - wave.time;
|
||||||
|
const progress = Math.min(elapsed / waveDuration, 1);
|
||||||
|
|
||||||
|
if (progress >= 1) {
|
||||||
|
// Remove expired waves
|
||||||
|
if (this.waveObjects[i]) {
|
||||||
|
this.scene.remove(this.waveObjects[i]);
|
||||||
|
this.waveObjects[i].geometry.dispose();
|
||||||
|
(this.waveObjects[i].material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
this.waveObjects.splice(i, 1);
|
||||||
|
this.repulsionWaves.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth easing function for softer expansion
|
||||||
|
const easeOut = 1 - Math.pow(1 - progress, 3);
|
||||||
|
|
||||||
|
// Expand radius with easing
|
||||||
|
wave.radius = wave.maxRadius * easeOut;
|
||||||
|
|
||||||
|
// Softer fade out - start from initial opacity
|
||||||
|
const initialOpacity = wave.opacity;
|
||||||
|
wave.opacity = initialOpacity * (1 - progress * progress); // Quadratic fade for softer effect
|
||||||
|
|
||||||
|
// Create or update wave object
|
||||||
|
if (!this.waveObjects[i]) {
|
||||||
|
this.createWaveObject(wave, i);
|
||||||
|
} else {
|
||||||
|
this.updateWaveObject(wave, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createWaveObject(wave: { x: number; y: number; radius: number; maxRadius: number; opacity: number }, index: number): void {
|
||||||
|
const geometry = new THREE.RingGeometry(0, 0.05, 32);
|
||||||
|
const material = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xffffff,
|
||||||
|
transparent: true,
|
||||||
|
opacity: wave.opacity * 0.5, // Softer initial opacity
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
});
|
||||||
|
|
||||||
|
const waveMesh = new THREE.Mesh(geometry, material);
|
||||||
|
waveMesh.position.set(wave.x, wave.y, 0);
|
||||||
|
this.scene.add(waveMesh);
|
||||||
|
this.waveObjects[index] = waveMesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateWaveObject(wave: { x: number; y: number; radius: number; maxRadius: number; opacity: number }, index: number): void {
|
||||||
|
const waveMesh = this.waveObjects[index];
|
||||||
|
if (!waveMesh) return;
|
||||||
|
|
||||||
|
const material = waveMesh.material as THREE.MeshBasicMaterial;
|
||||||
|
const progress = wave.radius / wave.maxRadius;
|
||||||
|
|
||||||
|
// Update geometry for expanding ring - thinner ring for softer look
|
||||||
|
waveMesh.geometry.dispose();
|
||||||
|
const innerRadius = Math.max(0, wave.radius * 0.85); // Thinner ring (85% instead of 70%)
|
||||||
|
waveMesh.geometry = new THREE.RingGeometry(innerRadius, wave.radius, 32);
|
||||||
|
|
||||||
|
// Softer opacity
|
||||||
|
material.opacity = wave.opacity * 0.6; // Additional softening multiplier
|
||||||
|
|
||||||
|
// Softer color variation (subtle blue to purple gradient)
|
||||||
|
const hue = (progress * 40 + 240) % 360; // Slower color transition
|
||||||
|
material.color.setHSL(hue / 360, 0.5, 0.8); // Lower saturation, higher lightness for softer look
|
||||||
|
}
|
||||||
|
|
||||||
|
private onWindowResize(): void {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
|
||||||
|
this.camera.aspect = width / height;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
this.renderer.setSize(width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Activity } from '../../../core/models/sport-goal.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-progress-card',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="bg-white/80 backdrop-blur-md rounded-lg shadow-md p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1 border border-gray-200/50">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<span class="text-2xl">{{ emoji }}</span>
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
class="text-2xl font-bold"
|
||||||
|
[class.text-green-600]="percentage >= 100"
|
||||||
|
[class.text-blue-600]="percentage >= 50 && percentage < 100"
|
||||||
|
[class.text-gray-600]="percentage < 50"
|
||||||
|
>
|
||||||
|
{{ percentage.toFixed(1) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex justify-between text-sm text-gray-600 mb-2">
|
||||||
|
<span>{{ currentLabel }}</span>
|
||||||
|
<span>{{ targetLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-4 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-4 rounded-full transition-all duration-500 ease-out relative overflow-hidden"
|
||||||
|
[ngClass]="{
|
||||||
|
'bg-green-500': percentage >= 100,
|
||||||
|
'bg-blue-500': percentage >= 50 && percentage < 100,
|
||||||
|
'bg-gray-400': percentage < 50
|
||||||
|
}"
|
||||||
|
[style.width.%]="Math.min(100, percentage)"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 w-full h-full animate-shimmer"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm text-gray-500 mb-4">
|
||||||
|
<p>{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="estimatedCompletion || dailyKmNeeded !== null || currentDailyAvg !== null || optimalDailyAvg !== null || daysAheadOrBehind !== null" class="bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg p-3 space-y-2 border border-gray-200">
|
||||||
|
<div *ngIf="daysAheadOrBehind !== null" class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-600 flex items-center gap-1">
|
||||||
|
<span>⏰</span>
|
||||||
|
<span>Schedule status:</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="font-semibold px-3 py-1 rounded-full text-sm"
|
||||||
|
[ngClass]="{
|
||||||
|
'bg-green-400 text-white': daysAheadOrBehind < 0,
|
||||||
|
'bg-yellow-400 text-white': daysAheadOrBehind > 0,
|
||||||
|
'bg-green-500 text-white': daysAheadOrBehind === 0
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span *ngIf="daysAheadOrBehind > 0">
|
||||||
|
{{ daysAheadOrBehind.toFixed(0) }} day{{ daysAheadOrBehind !== 1 ? 's' : '' }} behind
|
||||||
|
</span>
|
||||||
|
<span *ngIf="daysAheadOrBehind < 0">
|
||||||
|
{{ Math.abs(daysAheadOrBehind).toFixed(0) }} day{{ Math.abs(daysAheadOrBehind) !== 1 ? 's' : '' }} ahead
|
||||||
|
</span>
|
||||||
|
<span *ngIf="daysAheadOrBehind === 0">
|
||||||
|
✨ On schedule
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="currentDailyAvg !== null" class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-600 flex items-center gap-1">
|
||||||
|
<span>📊</span>
|
||||||
|
<span>Current daily avg:</span>
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold text-gray-900">
|
||||||
|
{{ currentDailyAvg.toFixed(2) }} km/day
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="optimalDailyAvg !== null" class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-600 flex items-center gap-1">
|
||||||
|
<span>⭐</span>
|
||||||
|
<span>Optimal daily avg:</span>
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold text-gray-900">
|
||||||
|
{{ optimalDailyAvg.toFixed(2) }} km/day
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="estimatedCompletion" class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-600 flex items-center gap-1">
|
||||||
|
<span>📅</span>
|
||||||
|
<span>Est. completion:</span>
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold text-gray-900">
|
||||||
|
{{ estimatedCompletion }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="dailyKmNeeded !== null" class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-600 flex items-center gap-1">
|
||||||
|
<span>🎯</span>
|
||||||
|
<span>Daily needed:</span>
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold text-gray-900">
|
||||||
|
{{ dailyKmNeeded.toFixed(2) }} km/day
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="recentActivities && recentActivities.length > 0" class="mt-4 bg-gradient-to-br from-blue-50 to-purple-50 rounded-lg p-4 border border-blue-200">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
|
||||||
|
<span>🏃</span>
|
||||||
|
<span>Last 3 Trainings</span>
|
||||||
|
</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
*ngFor="let activity of recentActivities; let i = index"
|
||||||
|
class="bg-white/60 backdrop-blur-sm rounded-lg p-3 border border-gray-200/50 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-gray-900 text-sm truncate mb-1">
|
||||||
|
{{ activity.name }}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-600">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span>📅</span>
|
||||||
|
<span>{{ formatActivityDate(activity.startDate) }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span>📏</span>
|
||||||
|
<span class="font-semibold text-gray-900">{{ formatDistance(activity.distance) }} km</span>
|
||||||
|
</span>
|
||||||
|
<span *ngIf="activity.movingTime > 0" class="flex items-center gap-1">
|
||||||
|
<span>⏱️</span>
|
||||||
|
<span>{{ formatTime(activity.movingTime) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-shimmer {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(255, 255, 255, 0.2) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 5s infinite;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ProgressCardComponent {
|
||||||
|
@Input() title = '';
|
||||||
|
@Input() percentage = 0;
|
||||||
|
@Input() currentLabel = '';
|
||||||
|
@Input() targetLabel = '';
|
||||||
|
@Input() description = '';
|
||||||
|
@Input() estimatedCompletion: string | null = null;
|
||||||
|
@Input() dailyKmNeeded: number | null = null;
|
||||||
|
@Input() currentDailyAvg: number | null = null;
|
||||||
|
@Input() optimalDailyAvg: number | null = null;
|
||||||
|
@Input() daysAheadOrBehind: number | null = null;
|
||||||
|
@Input() recentActivities: Activity[] | null = null;
|
||||||
|
@Input() emoji = '🏃';
|
||||||
|
|
||||||
|
readonly Math = Math;
|
||||||
|
|
||||||
|
formatDistance(meters: number): string {
|
||||||
|
return (meters / 1000).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTime(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatActivityDate(date: Date | string): string {
|
||||||
|
const activityDate = typeof date === 'string' ? new Date(date) : date;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Normalize both dates to midnight (start of day) for accurate day comparison
|
||||||
|
const activityDay = new Date(
|
||||||
|
activityDate.getFullYear(),
|
||||||
|
activityDate.getMonth(),
|
||||||
|
activityDate.getDate()
|
||||||
|
);
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
|
||||||
|
const diffTime = today.getTime() - activityDay.getTime();
|
||||||
|
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays === 0) {
|
||||||
|
return 'Today';
|
||||||
|
} else if (diffDays === 1) {
|
||||||
|
return 'Yesterday';
|
||||||
|
} else if (diffDays < 7) {
|
||||||
|
return `${diffDays} days ago`;
|
||||||
|
} else {
|
||||||
|
return activityDate.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: activityDate.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-progress-ring',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="relative inline-flex items-center justify-center">
|
||||||
|
<svg class="transform -rotate-90" [attr.width]="size" [attr.height]="size">
|
||||||
|
<!-- Background circle -->
|
||||||
|
<circle
|
||||||
|
[attr.cx]="center"
|
||||||
|
[attr.cy]="center"
|
||||||
|
[attr.r]="radius"
|
||||||
|
fill="none"
|
||||||
|
[attr.stroke]="backgroundColor"
|
||||||
|
[attr.stroke-width]="strokeWidth"
|
||||||
|
class="opacity-20"
|
||||||
|
/>
|
||||||
|
<!-- Progress circle -->
|
||||||
|
<circle
|
||||||
|
[attr.cx]="center"
|
||||||
|
[attr.cy]="center"
|
||||||
|
[attr.r]="radius"
|
||||||
|
fill="none"
|
||||||
|
[attr.stroke]="progressColor"
|
||||||
|
[attr.stroke-width]="strokeWidth"
|
||||||
|
[attr.stroke-linecap]="'round'"
|
||||||
|
[attr.stroke-dasharray]="circumference"
|
||||||
|
[attr.stroke-dashoffset]="offset"
|
||||||
|
class="transition-all duration-500 ease-out"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold" [style.color]="progressColor">
|
||||||
|
{{ percentage.toFixed(0) }}%
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500" *ngIf="label">{{ label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [],
|
||||||
|
})
|
||||||
|
export class ProgressRingComponent implements OnChanges {
|
||||||
|
@Input() percentage: number = 0;
|
||||||
|
@Input() size: number = 120;
|
||||||
|
@Input() strokeWidth: number = 8;
|
||||||
|
@Input() progressColor: string = '#3b82f6';
|
||||||
|
@Input() backgroundColor: string = '#e5e7eb';
|
||||||
|
@Input() label: string = '';
|
||||||
|
|
||||||
|
radius: number = 0;
|
||||||
|
center: number = 0;
|
||||||
|
circumference: number = 0;
|
||||||
|
offset: number = 0;
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
this.radius = (this.size - this.strokeWidth) / 2;
|
||||||
|
this.center = this.size / 2;
|
||||||
|
this.circumference = 2 * Math.PI * this.radius;
|
||||||
|
this.offset = this.circumference - (this.percentage / 100) * this.circumference;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
ElementRef,
|
||||||
|
ViewChild,
|
||||||
|
AfterViewInit,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
interface Bullet {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
speed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Enemy {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
alive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-space-invaders',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="space-invaders-container">
|
||||||
|
<canvas #canvas class="w-full h-full rounded-lg"></canvas>
|
||||||
|
<div class="absolute top-4 left-4 bg-black/50 text-white px-4 py-2 rounded-lg text-sm font-bold">
|
||||||
|
Score: {{ score }} | Lives: {{ lives }}
|
||||||
|
</div>
|
||||||
|
<div class="absolute top-4 right-4 bg-black/50 text-white px-4 py-2 rounded-lg text-sm">
|
||||||
|
← → to move • Space to shoot
|
||||||
|
</div>
|
||||||
|
@if (gameOver) {
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center bg-black/70 rounded-lg">
|
||||||
|
<div class="text-center text-white">
|
||||||
|
<h2 class="text-4xl font-bold mb-4">Game Over!</h2>
|
||||||
|
<p class="text-2xl mb-4">Final Score: {{ score }}</p>
|
||||||
|
<button
|
||||||
|
(click)="restart()"
|
||||||
|
class="px-6 py-3 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-all"
|
||||||
|
>
|
||||||
|
Play Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.space-invaders-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #000000;
|
||||||
|
border-radius: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class SpaceInvadersComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
|
private ctx!: CanvasRenderingContext2D;
|
||||||
|
private animationId: number | null = null;
|
||||||
|
private player = { x: 0, y: 0, width: 50, height: 30, speed: 5 };
|
||||||
|
private bullets: Bullet[] = [];
|
||||||
|
private enemies: Enemy[] = [];
|
||||||
|
private enemyBullets: Bullet[] = [];
|
||||||
|
private keys: { [key: string]: boolean } = {};
|
||||||
|
private lastShot = 0;
|
||||||
|
private enemyDirection = 1;
|
||||||
|
private enemySpeed = 1;
|
||||||
|
private lastEnemyShot = 0;
|
||||||
|
score = 0;
|
||||||
|
lives = 3;
|
||||||
|
gameOver = false;
|
||||||
|
|
||||||
|
ngOnInit(): void {}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
this.ctx = canvas.getContext('2d')!;
|
||||||
|
this.resizeCanvas();
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.initGame();
|
||||||
|
this.animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.animationId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
}
|
||||||
|
this.removeEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resizeCanvas(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
const container = canvas.parentElement!;
|
||||||
|
canvas.width = container.clientWidth;
|
||||||
|
canvas.height = container.clientHeight;
|
||||||
|
this.player.x = canvas.width / 2 - this.player.width / 2;
|
||||||
|
this.player.y = canvas.height - 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
private initGame(): void {
|
||||||
|
this.enemies = [];
|
||||||
|
this.bullets = [];
|
||||||
|
this.enemyBullets = [];
|
||||||
|
this.score = 0;
|
||||||
|
this.lives = 3;
|
||||||
|
this.gameOver = false;
|
||||||
|
this.enemyDirection = 1;
|
||||||
|
this.enemySpeed = 1;
|
||||||
|
|
||||||
|
// Create enemies in grid
|
||||||
|
const rows = 5;
|
||||||
|
const cols = 10;
|
||||||
|
const spacing = 60;
|
||||||
|
const startX = 50;
|
||||||
|
const startY = 50;
|
||||||
|
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
this.enemies.push({
|
||||||
|
x: startX + col * spacing,
|
||||||
|
y: startY + row * spacing,
|
||||||
|
width: 40,
|
||||||
|
height: 30,
|
||||||
|
alive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventListeners(): void {
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
this.keys[e.key] = true;
|
||||||
|
if (e.key === ' ' && !this.gameOver) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.shoot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('keyup', (e) => {
|
||||||
|
this.keys[e.key] = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => this.resizeCanvas());
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeEventListeners(): void {
|
||||||
|
window.removeEventListener('keydown', () => {});
|
||||||
|
window.removeEventListener('keyup', () => {});
|
||||||
|
window.removeEventListener('resize', () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePlayer(): void {
|
||||||
|
if (this.keys['ArrowLeft'] && this.player.x > 0) {
|
||||||
|
this.player.x -= this.player.speed;
|
||||||
|
}
|
||||||
|
if (this.keys['ArrowRight'] && this.player.x < this.canvasRef.nativeElement.width - this.player.width) {
|
||||||
|
this.player.x += this.player.speed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shoot(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastShot > 200) {
|
||||||
|
this.bullets.push({
|
||||||
|
x: this.player.x + this.player.width / 2,
|
||||||
|
y: this.player.y,
|
||||||
|
speed: -8,
|
||||||
|
});
|
||||||
|
this.lastShot = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateBullets(): void {
|
||||||
|
// Player bullets
|
||||||
|
this.bullets = this.bullets.filter((bullet) => {
|
||||||
|
bullet.y += bullet.speed;
|
||||||
|
return bullet.y > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enemy bullets
|
||||||
|
this.enemyBullets = this.enemyBullets.filter((bullet) => {
|
||||||
|
bullet.y += bullet.speed;
|
||||||
|
return bullet.y < this.canvasRef.nativeElement.height;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateEnemies(): void {
|
||||||
|
if (this.enemies.length === 0) {
|
||||||
|
this.initGame();
|
||||||
|
this.enemySpeed += 0.5;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move enemies
|
||||||
|
let shouldMoveDown = false;
|
||||||
|
for (const enemy of this.enemies) {
|
||||||
|
if (!enemy.alive) continue;
|
||||||
|
enemy.x += this.enemyDirection * this.enemySpeed;
|
||||||
|
if (enemy.x <= 0 || enemy.x >= this.canvasRef.nativeElement.width - enemy.width) {
|
||||||
|
shouldMoveDown = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldMoveDown) {
|
||||||
|
this.enemyDirection *= -1;
|
||||||
|
for (const enemy of this.enemies) {
|
||||||
|
if (enemy.alive) {
|
||||||
|
enemy.y += 20;
|
||||||
|
if (enemy.y > this.player.y) {
|
||||||
|
this.gameOver = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enemy shooting
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastEnemyShot > 1000 && this.enemies.some((e) => e.alive)) {
|
||||||
|
const aliveEnemies = this.enemies.filter((e) => e.alive);
|
||||||
|
if (aliveEnemies.length > 0) {
|
||||||
|
const randomEnemy = aliveEnemies[Math.floor(Math.random() * aliveEnemies.length)];
|
||||||
|
this.enemyBullets.push({
|
||||||
|
x: randomEnemy.x + randomEnemy.width / 2,
|
||||||
|
y: randomEnemy.y + randomEnemy.height,
|
||||||
|
speed: 3,
|
||||||
|
});
|
||||||
|
this.lastEnemyShot = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkCollisions(): void {
|
||||||
|
// Player bullets vs enemies
|
||||||
|
for (const bullet of this.bullets) {
|
||||||
|
for (const enemy of this.enemies) {
|
||||||
|
if (
|
||||||
|
enemy.alive &&
|
||||||
|
bullet.x > enemy.x &&
|
||||||
|
bullet.x < enemy.x + enemy.width &&
|
||||||
|
bullet.y > enemy.y &&
|
||||||
|
bullet.y < enemy.y + enemy.height
|
||||||
|
) {
|
||||||
|
enemy.alive = false;
|
||||||
|
this.bullets = this.bullets.filter((b) => b !== bullet);
|
||||||
|
this.score += 10;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enemy bullets vs player
|
||||||
|
for (const bullet of this.enemyBullets) {
|
||||||
|
if (
|
||||||
|
bullet.x > this.player.x &&
|
||||||
|
bullet.x < this.player.x + this.player.width &&
|
||||||
|
bullet.y > this.player.y &&
|
||||||
|
bullet.y < this.player.y + this.player.height
|
||||||
|
) {
|
||||||
|
this.enemyBullets = this.enemyBullets.filter((b) => b !== bullet);
|
||||||
|
this.lives--;
|
||||||
|
if (this.lives <= 0) {
|
||||||
|
this.gameOver = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private draw(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
const ctx = this.ctx;
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
ctx.fillStyle = '#000000';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Draw stars
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const x = (i * 37) % canvas.width;
|
||||||
|
const y = (i * 53) % canvas.height;
|
||||||
|
ctx.fillRect(x, y, 2, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw player
|
||||||
|
ctx.fillStyle = '#00ff00';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(this.player.x + this.player.width / 2, this.player.y);
|
||||||
|
ctx.lineTo(this.player.x, this.player.y + this.player.height);
|
||||||
|
ctx.lineTo(this.player.x + this.player.width, this.player.y + this.player.height);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Draw bullets
|
||||||
|
ctx.fillStyle = '#ffff00';
|
||||||
|
for (const bullet of this.bullets) {
|
||||||
|
ctx.fillRect(bullet.x - 2, bullet.y, 4, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw enemy bullets
|
||||||
|
ctx.fillStyle = '#ff0000';
|
||||||
|
for (const bullet of this.enemyBullets) {
|
||||||
|
ctx.fillRect(bullet.x - 2, bullet.y, 4, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw enemies
|
||||||
|
ctx.fillStyle = '#ff00ff';
|
||||||
|
for (const enemy of this.enemies) {
|
||||||
|
if (enemy.alive) {
|
||||||
|
ctx.fillRect(enemy.x, enemy.y, enemy.width, enemy.height);
|
||||||
|
// Draw eyes
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(enemy.x + 8, enemy.y + 8, 6, 6);
|
||||||
|
ctx.fillRect(enemy.x + enemy.width - 14, enemy.y + 8, 6, 6);
|
||||||
|
ctx.fillStyle = '#ff00ff';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private update(): void {
|
||||||
|
if (this.gameOver) return;
|
||||||
|
|
||||||
|
this.updatePlayer();
|
||||||
|
this.updateBullets();
|
||||||
|
this.updateEnemies();
|
||||||
|
this.checkCollisions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private animate = (): void => {
|
||||||
|
this.update();
|
||||||
|
this.draw();
|
||||||
|
this.animationId = requestAnimationFrame(this.animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
restart(): void {
|
||||||
|
this.initGame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
ElementRef,
|
||||||
|
ViewChild,
|
||||||
|
Input,
|
||||||
|
AfterViewInit,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-three-geometric',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<canvas #canvas class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ThreeGeometricComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
@Input() type: 'floating' | 'rings' | 'particles' = 'floating';
|
||||||
|
@Input() color: string = '#3b82f6';
|
||||||
|
|
||||||
|
private scene!: THREE.Scene;
|
||||||
|
private camera!: THREE.PerspectiveCamera;
|
||||||
|
private renderer!: THREE.WebGLRenderer;
|
||||||
|
private animationId: number | null = null;
|
||||||
|
private objects: THREE.Object3D[] = [];
|
||||||
|
|
||||||
|
ngOnInit(): void {}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.initThree();
|
||||||
|
this.createGeometry();
|
||||||
|
this.animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.animationId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
}
|
||||||
|
this.objects.forEach((obj) => {
|
||||||
|
if (obj instanceof THREE.Mesh) {
|
||||||
|
obj.geometry.dispose();
|
||||||
|
if (Array.isArray(obj.material)) {
|
||||||
|
obj.material.forEach((m) => m.dispose());
|
||||||
|
} else {
|
||||||
|
obj.material.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (this.renderer) {
|
||||||
|
this.renderer.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private initThree(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
const width = canvas.clientWidth;
|
||||||
|
const height = canvas.clientHeight;
|
||||||
|
|
||||||
|
this.scene = new THREE.Scene();
|
||||||
|
this.scene.background = null;
|
||||||
|
|
||||||
|
this.camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
||||||
|
this.camera.position.z = 5;
|
||||||
|
|
||||||
|
this.renderer = new THREE.WebGLRenderer({
|
||||||
|
canvas,
|
||||||
|
alpha: true,
|
||||||
|
antialias: true,
|
||||||
|
});
|
||||||
|
this.renderer.setSize(width, height);
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => this.onWindowResize());
|
||||||
|
}
|
||||||
|
|
||||||
|
private createGeometry(): void {
|
||||||
|
const color = new THREE.Color(this.color);
|
||||||
|
|
||||||
|
if (this.type === 'floating') {
|
||||||
|
this.createFloatingShapes(color);
|
||||||
|
} else if (this.type === 'rings') {
|
||||||
|
this.createRings(color);
|
||||||
|
} else if (this.type === 'particles') {
|
||||||
|
this.createParticles(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createFloatingShapes(color: THREE.Color): void {
|
||||||
|
// Create floating geometric shapes
|
||||||
|
const shapes = [
|
||||||
|
new THREE.BoxGeometry(0.5, 0.5, 0.5),
|
||||||
|
new THREE.SphereGeometry(0.3, 16, 16),
|
||||||
|
new THREE.OctahedronGeometry(0.4, 0),
|
||||||
|
new THREE.TetrahedronGeometry(0.4, 0),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const geometry = shapes[i % shapes.length];
|
||||||
|
const material = new THREE.MeshStandardMaterial({
|
||||||
|
color: color.clone().multiplyScalar(0.8 + Math.random() * 0.4),
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.3,
|
||||||
|
metalness: 0.7,
|
||||||
|
roughness: 0.3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
mesh.position.set(
|
||||||
|
(Math.random() - 0.5) * 8,
|
||||||
|
(Math.random() - 0.5) * 8,
|
||||||
|
(Math.random() - 0.5) * 5
|
||||||
|
);
|
||||||
|
mesh.rotation.set(
|
||||||
|
Math.random() * Math.PI,
|
||||||
|
Math.random() * Math.PI,
|
||||||
|
Math.random() * Math.PI
|
||||||
|
);
|
||||||
|
|
||||||
|
(mesh.userData as any).speed = {
|
||||||
|
x: (Math.random() - 0.5) * 0.01,
|
||||||
|
y: (Math.random() - 0.5) * 0.01,
|
||||||
|
z: (Math.random() - 0.5) * 0.01,
|
||||||
|
rotX: (Math.random() - 0.5) * 0.02,
|
||||||
|
rotY: (Math.random() - 0.5) * 0.02,
|
||||||
|
rotZ: (Math.random() - 0.5) * 0.02,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.scene.add(mesh);
|
||||||
|
this.objects.push(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ambient light
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
|
||||||
|
this.scene.add(ambientLight);
|
||||||
|
|
||||||
|
const pointLight = new THREE.PointLight(color, 1, 100);
|
||||||
|
pointLight.position.set(5, 5, 5);
|
||||||
|
this.scene.add(pointLight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRings(color: THREE.Color): void {
|
||||||
|
// Create rotating rings
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const geometry = new THREE.TorusGeometry(1 + i * 0.5, 0.05, 8, 50);
|
||||||
|
const material = new THREE.MeshStandardMaterial({
|
||||||
|
color: color.clone().multiplyScalar(0.6 + i * 0.2),
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.4,
|
||||||
|
metalness: 0.8,
|
||||||
|
roughness: 0.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ring = new THREE.Mesh(geometry, material);
|
||||||
|
ring.rotation.x = Math.PI / 2;
|
||||||
|
ring.position.z = -i * 0.5;
|
||||||
|
|
||||||
|
(ring.userData as any).speed = 0.005 + i * 0.002;
|
||||||
|
|
||||||
|
this.scene.add(ring);
|
||||||
|
this.objects.push(ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||||
|
this.scene.add(ambientLight);
|
||||||
|
|
||||||
|
const directionalLight = new THREE.DirectionalLight(color, 0.8);
|
||||||
|
directionalLight.position.set(5, 5, 5);
|
||||||
|
this.scene.add(directionalLight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createParticles(color: THREE.Color): void {
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
const count = 200;
|
||||||
|
const positions = new Float32Array(count * 3);
|
||||||
|
|
||||||
|
for (let i = 0; i < count * 3; i++) {
|
||||||
|
positions[i] = (Math.random() - 0.5) * 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
|
||||||
|
const material = new THREE.PointsMaterial({
|
||||||
|
color: color,
|
||||||
|
size: 0.1,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.6,
|
||||||
|
});
|
||||||
|
|
||||||
|
const points = new THREE.Points(geometry, material);
|
||||||
|
this.scene.add(points);
|
||||||
|
this.objects.push(points);
|
||||||
|
}
|
||||||
|
|
||||||
|
private animate = (): void => {
|
||||||
|
this.animationId = requestAnimationFrame(this.animate);
|
||||||
|
|
||||||
|
this.objects.forEach((obj) => {
|
||||||
|
if (obj instanceof THREE.Mesh) {
|
||||||
|
const userData = obj.userData as any;
|
||||||
|
if (userData.speed) {
|
||||||
|
if (userData.speed.x !== undefined) {
|
||||||
|
obj.position.x += userData.speed.x;
|
||||||
|
obj.position.y += userData.speed.y;
|
||||||
|
obj.position.z += userData.speed.z;
|
||||||
|
obj.rotation.x += userData.speed.rotX;
|
||||||
|
obj.rotation.y += userData.speed.rotY;
|
||||||
|
obj.rotation.z += userData.speed.rotZ;
|
||||||
|
} else if (userData.speed !== undefined) {
|
||||||
|
obj.rotation.y += userData.speed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (obj instanceof THREE.Points) {
|
||||||
|
obj.rotation.y += 0.001;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onWindowResize(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
const width = canvas.clientWidth;
|
||||||
|
const height = canvas.clientHeight;
|
||||||
|
|
||||||
|
this.camera.aspect = width / height;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
this.renderer.setSize(width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
ElementRef,
|
||||||
|
ViewChild,
|
||||||
|
Input,
|
||||||
|
AfterViewInit,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-three-trophy',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="relative w-full h-full">
|
||||||
|
<canvas #canvas class="w-full h-full"></canvas>
|
||||||
|
<div
|
||||||
|
*ngIf="progress !== null"
|
||||||
|
class="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-black bg-opacity-50 text-white px-4 py-2 rounded-lg text-sm font-bold"
|
||||||
|
>
|
||||||
|
{{ progress.toFixed(1) }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ThreeTrophyComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
@Input() progress: number | null = null;
|
||||||
|
@Input() size: number = 200;
|
||||||
|
|
||||||
|
private scene!: THREE.Scene;
|
||||||
|
private camera!: THREE.PerspectiveCamera;
|
||||||
|
private renderer!: THREE.WebGLRenderer;
|
||||||
|
private trophy!: THREE.Group;
|
||||||
|
private animationId: number | null = null;
|
||||||
|
private mouseX = 0;
|
||||||
|
private mouseY = 0;
|
||||||
|
|
||||||
|
ngOnInit(): void {}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.initThree();
|
||||||
|
this.createTrophy();
|
||||||
|
this.animate();
|
||||||
|
this.addMouseInteraction();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.animationId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
}
|
||||||
|
if (this.renderer) {
|
||||||
|
this.renderer.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private initThree(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
const width = canvas.clientWidth;
|
||||||
|
const height = canvas.clientHeight;
|
||||||
|
|
||||||
|
// Scene
|
||||||
|
this.scene = new THREE.Scene();
|
||||||
|
this.scene.background = null;
|
||||||
|
|
||||||
|
// Camera
|
||||||
|
this.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000);
|
||||||
|
this.camera.position.set(0, 2, 5);
|
||||||
|
this.camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
// Renderer
|
||||||
|
this.renderer = new THREE.WebGLRenderer({
|
||||||
|
canvas: canvas,
|
||||||
|
antialias: true,
|
||||||
|
alpha: true,
|
||||||
|
});
|
||||||
|
this.renderer.setSize(width, height);
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
this.renderer.shadowMap.enabled = true;
|
||||||
|
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
|
||||||
|
// Lights
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||||
|
this.scene.add(ambientLight);
|
||||||
|
|
||||||
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||||
|
directionalLight.position.set(5, 10, 5);
|
||||||
|
directionalLight.castShadow = true;
|
||||||
|
this.scene.add(directionalLight);
|
||||||
|
|
||||||
|
const pointLight = new THREE.PointLight(0x4f46e5, 1, 100);
|
||||||
|
pointLight.position.set(-5, 5, 5);
|
||||||
|
this.scene.add(pointLight);
|
||||||
|
|
||||||
|
// Handle resize
|
||||||
|
window.addEventListener('resize', () => this.onWindowResize());
|
||||||
|
}
|
||||||
|
|
||||||
|
private createTrophy(): void {
|
||||||
|
this.trophy = new THREE.Group();
|
||||||
|
|
||||||
|
// Trophy base
|
||||||
|
const baseGeometry = new THREE.CylinderGeometry(0.8, 1, 0.3, 32);
|
||||||
|
const baseMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xffd700,
|
||||||
|
metalness: 0.8,
|
||||||
|
roughness: 0.2,
|
||||||
|
});
|
||||||
|
const base = new THREE.Mesh(baseGeometry, baseMaterial);
|
||||||
|
base.position.y = -1.5;
|
||||||
|
base.castShadow = true;
|
||||||
|
base.receiveShadow = true;
|
||||||
|
this.trophy.add(base);
|
||||||
|
|
||||||
|
// Trophy stem
|
||||||
|
const stemGeometry = new THREE.CylinderGeometry(0.15, 0.15, 1.5, 16);
|
||||||
|
const stemMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xffd700,
|
||||||
|
metalness: 0.8,
|
||||||
|
roughness: 0.2,
|
||||||
|
});
|
||||||
|
const stem = new THREE.Mesh(stemGeometry, stemMaterial);
|
||||||
|
stem.position.y = -0.5;
|
||||||
|
stem.castShadow = true;
|
||||||
|
this.trophy.add(stem);
|
||||||
|
|
||||||
|
// Trophy cup (bottom)
|
||||||
|
const cupBottomGeometry = new THREE.ConeGeometry(0.6, 0.8, 32);
|
||||||
|
const cupMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xffd700,
|
||||||
|
metalness: 0.9,
|
||||||
|
roughness: 0.1,
|
||||||
|
});
|
||||||
|
const cupBottom = new THREE.Mesh(cupBottomGeometry, cupMaterial);
|
||||||
|
cupBottom.position.y = 0.3;
|
||||||
|
cupBottom.rotation.x = Math.PI;
|
||||||
|
cupBottom.castShadow = true;
|
||||||
|
this.trophy.add(cupBottom);
|
||||||
|
|
||||||
|
// Trophy cup (top rim)
|
||||||
|
const cupTopGeometry = new THREE.TorusGeometry(0.6, 0.05, 16, 32);
|
||||||
|
const cupTop = new THREE.Mesh(cupTopGeometry, cupMaterial);
|
||||||
|
cupTop.position.y = 0.7;
|
||||||
|
cupTop.castShadow = true;
|
||||||
|
this.trophy.add(cupTop);
|
||||||
|
|
||||||
|
// Trophy handles
|
||||||
|
const handleGeometry = new THREE.TorusGeometry(0.3, 0.05, 8, 16);
|
||||||
|
const handle1 = new THREE.Mesh(handleGeometry, cupMaterial);
|
||||||
|
handle1.position.set(0.6, 0.5, 0);
|
||||||
|
handle1.rotation.z = Math.PI / 2;
|
||||||
|
handle1.castShadow = true;
|
||||||
|
this.trophy.add(handle1);
|
||||||
|
|
||||||
|
const handle2 = new THREE.Mesh(handleGeometry, cupMaterial);
|
||||||
|
handle2.position.set(-0.6, 0.5, 0);
|
||||||
|
handle2.rotation.z = -Math.PI / 2;
|
||||||
|
handle2.castShadow = true;
|
||||||
|
this.trophy.add(handle2);
|
||||||
|
|
||||||
|
// Progress indicator - particles
|
||||||
|
if (this.progress !== null) {
|
||||||
|
const particleCount = Math.floor((this.progress / 100) * 50);
|
||||||
|
const particles = new THREE.BufferGeometry();
|
||||||
|
const positions = new Float32Array(particleCount * 3);
|
||||||
|
|
||||||
|
for (let i = 0; i < particleCount; i++) {
|
||||||
|
const angle = (i / particleCount) * Math.PI * 2;
|
||||||
|
const radius = 0.8;
|
||||||
|
positions[i * 3] = Math.cos(angle) * radius;
|
||||||
|
positions[i * 3 + 1] = 0.7 + Math.sin(angle) * 0.3;
|
||||||
|
positions[i * 3 + 2] = Math.sin(angle) * radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
particles.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
|
||||||
|
const particleMaterial = new THREE.PointsMaterial({
|
||||||
|
color: 0x4f46e5,
|
||||||
|
size: 0.05,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
const particleSystem = new THREE.Points(particles, particleMaterial);
|
||||||
|
this.trophy.add(particleSystem);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scene.add(this.trophy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addMouseInteraction(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
canvas.addEventListener('mousemove', (event) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
this.mouseX = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
this.mouseY = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('mouseleave', () => {
|
||||||
|
this.mouseX = 0;
|
||||||
|
this.mouseY = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private animate = (): void => {
|
||||||
|
this.animationId = requestAnimationFrame(this.animate);
|
||||||
|
|
||||||
|
// Rotate trophy - much slower
|
||||||
|
if (this.trophy) {
|
||||||
|
this.trophy.rotation.y += 0.001;
|
||||||
|
|
||||||
|
// Subtle mouse interaction
|
||||||
|
this.trophy.rotation.y += this.mouseX * 0.01;
|
||||||
|
this.trophy.rotation.x = this.mouseY * 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onWindowResize(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
const width = canvas.clientWidth;
|
||||||
|
const height = canvas.clientHeight;
|
||||||
|
|
||||||
|
this.camera.aspect = width / height;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
this.renderer.setSize(width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Check if a date is within the year 2026
|
||||||
|
*/
|
||||||
|
export function isIn2026(date: Date): boolean {
|
||||||
|
return date.getFullYear() === 2026;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the start date of 2026
|
||||||
|
*/
|
||||||
|
export function get2026Start(): Date {
|
||||||
|
return new Date('2026-01-01T00:00:00Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the end date of 2026
|
||||||
|
*/
|
||||||
|
export function get2026End(): Date {
|
||||||
|
return new Date('2026-12-31T23:59:59Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get days elapsed in 2026
|
||||||
|
*/
|
||||||
|
export function getDaysElapsedIn2026(): number {
|
||||||
|
const now = new Date();
|
||||||
|
const start = get2026Start();
|
||||||
|
const diff = now.getTime() - start.getTime();
|
||||||
|
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total days in 2026
|
||||||
|
*/
|
||||||
|
export function getTotalDaysIn2026(): number {
|
||||||
|
const start = get2026Start();
|
||||||
|
const end = get2026End();
|
||||||
|
const diff = end.getTime() - start.getTime();
|
||||||
|
return Math.floor(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate ideal progress percentage based on linear progression
|
||||||
|
*/
|
||||||
|
export function getIdealProgress(): number {
|
||||||
|
const elapsed = getDaysElapsedIn2026();
|
||||||
|
const total = getTotalDaysIn2026();
|
||||||
|
return Math.min(100, (elapsed / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date to readable string
|
||||||
|
*/
|
||||||
|
export function formatDate(date: Date): string {
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert meters to kilometers
|
||||||
|
*/
|
||||||
|
export function metersToKm(meters: number): number {
|
||||||
|
return meters / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Gaming Logos
|
||||||
|
|
||||||
|
Logo files in this folder:
|
||||||
|
|
||||||
|
- `tft.svg` ✓ - Teamfight Tactics logo
|
||||||
|
- `lol.svg` ✓ - League of Legends logo
|
||||||
|
- `rl.svg` ✓ - Rocket League logo
|
||||||
|
- `faceit.svg` ✓ - Faceit logo
|
||||||
|
|
||||||
|
All logos are loaded and ready to use!
|
||||||
|
|
||||||
|
Supported formats: SVG (recommended) or PNG
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1520" viewBox="29.3 101.1 451.7 357.9" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m481 104.8c0-1.8-1.9-3.7-1.9-3.7-1.8 0-1.8 0-3.7 1.9-37.5 58.1-76.8 116.2-114.3 176.2h-326.2c-3.7 0-5.6 5.6-1.8 7.5 134.9 50.5 331.7 127.3 440.4 170.4 3.7 1.9 7.5-1.9 7.5-3.7z" fill="#fd5a00"/><path d="m481 104.8c0-1.8-1.9-3.7-1.9-3.7-1.8 0-1.8 0-3.7 1.9-37.5 58.1-76.8 116.2-114.3 176.2l119.9 1.23z" fill="#ff690a"/></svg>
|
||||||
|
After Width: | Height: | Size: 432 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg version="1.1" id="katman_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" style="enable-background:new 0 0 882.72 703.44;" xml:space="preserve" viewBox="237.72 133.67 408.05 435.23"> <style type="text/css"> .st0{clip-path:url(#SVGID_2_);} .st1{fill:#C28F2B;} </style> <g> <defs> <rect id="SVGID_1_" x="237.72" y="133.68" width="408" height="435.2"></rect> </defs> <clipPath id="SVGID_2_"> <use xlink:href="#SVGID_1_" style="overflow:visible;"></use> </clipPath> <g class="st0"> <path class="st1" d="M262.29,266.28c-15.66,28.38-24.57,60.88-24.57,95.44s8.91,67.09,24.57,95.47V266.28z"></path> <path class="st1" d="M441.72,161.18c-16.55,0-32.61,2-48.02,5.63v31.56c15.26-4.3,31.35-6.66,48.02-6.66 c95.5,0,172.93,75.88,172.93,169.5c0,42.16-15.75,80.72-41.74,110.38l-4.93,17.3l-10.91,38.29 c53.54-36.14,88.68-96.75,88.68-165.41C645.72,250.96,554.39,161.18,441.72,161.18z"></path> <path class="st1" d="M393.7,465.8h156.47h3.42c26.48-27.2,42.79-64.03,42.79-104.59c0-83.72-69.23-151.57-154.65-151.57 c-16.77,0-32.89,2.67-48.02,7.48V465.8z"></path> <path class="st1" d="M375.2,133.68h-116.4l21.98,44.86v345.51l-21.98,44.83h267.13l24.23-84.9H375.2V133.68z"></path> </g> </g> </svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg version="1.0" id="katman_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" style="enable-background:new 0 0 96 72;" xml:space="preserve" viewBox="26.5 15.98 43.12 40.03"> <style type="text/css"> .st0{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_1_);} </style> <linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="34.8739" y1="12.8925" x2="64.828" y2="60.9741" gradientTransform="matrix(1 0 0 -1 0 74)"> <stop offset="4.909790e-02" style="stop-color:#AF893D"></stop> <stop offset="0.2178" style="stop-color:#D29D40"></stop> <stop offset="0.6393" style="stop-color:#FFCC74"></stop> <stop offset="1.8142" style="stop-color:#E8B55D"></stop> </linearGradient> <path class="st0" d="M30.7,40c0.3,0.3,0.9,0.7,1.9,1.3v3.6l12,7l0-1.7c3.6,1.6,6.4,2.6,8.4,2.9l0.1,0L48,56L30.7,45.9V40z M48,16 l17.3,10v15.2c1.7,1.2,3.1,2.7,4.3,4.5c-0.3-0.1-0.9-0.4-1.8-0.8c1.1,2.5,1.3,6.3-2.8,7.2c-9.8,2.3-22.6-4.1-27.4-7.3 c2.5,1,4.8,1.9,7,2.6l0-1.6c-2.3-0.8-6.3-2.4-10.6-5.3c-4-2.7-7.1-6-7.5-6.6c0.7,0.2,1.2,0.3,1.7,0.3c-1.5-2.7,0.1-5.6,2.6-6.7V26 L48,16z M48,18.1L32.6,27v7.2c-0.7-0.6-1.7-2.3-1.9-3.3c-0.7,0.5-0.8,1.5-0.4,2.6c1.8,4.6,9.4,9.5,14.2,12.1l0-12.2h-7.5l-2.6-5.9 h27.2L59,33.5h-7.5l0,15.7c2.2,0.4,4.3,0.7,6.2,0.8c8.3,0.4,8.9-2,7.5-4l-4.8,2.8c-1.3,0.1-2.5,0.1-3.6-0.1l6.6-3.8V27L48,18.1z"></path> </svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Production environment.
|
||||||
|
* Non-secret config from process.env at build time.
|
||||||
|
* API keys stay in backend .env only.
|
||||||
|
*/
|
||||||
|
declare const process: { env: Record<string, string | undefined> };
|
||||||
|
|
||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
strava: {
|
||||||
|
clientId: process.env['STRAVA_CLIENT_ID'] || '',
|
||||||
|
redirectUri: process.env['STRAVA_REDIRECT_URI'] || '',
|
||||||
|
accessToken: '',
|
||||||
|
refreshToken: '',
|
||||||
|
tokenExpiresAt: null as number | null,
|
||||||
|
},
|
||||||
|
riot: {
|
||||||
|
region: process.env['RIOT_REGION'] || 'eun1',
|
||||||
|
summonerNames: {
|
||||||
|
lol: process.env['RIOT_SUMMONER_NAME_LOL'] || '',
|
||||||
|
tft: process.env['RIOT_SUMMONER_NAME_TFT'] || '',
|
||||||
|
},
|
||||||
|
tagLine: process.env['RIOT_TAG_LINE'] || '',
|
||||||
|
},
|
||||||
|
faceit: {
|
||||||
|
userId: process.env['FACEIT_USER_ID'] || '',
|
||||||
|
},
|
||||||
|
tracker: {
|
||||||
|
rocketLeague: {
|
||||||
|
platform: process.env['ROCKET_LEAGUE_PLATFORM'] || 'steam',
|
||||||
|
username: process.env['ROCKET_LEAGUE_USERNAME'] || '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
goals: {
|
||||||
|
sport: {
|
||||||
|
bike: 7500,
|
||||||
|
run: 2500,
|
||||||
|
swim: 250,
|
||||||
|
},
|
||||||
|
endDate: new Date('2026-12-31'),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Development environment.
|
||||||
|
* No secrets - API keys are handled by the backend proxy.
|
||||||
|
* Copy .env.example to .env and configure for local backend.
|
||||||
|
*/
|
||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
strava: {
|
||||||
|
clientId: '196635',
|
||||||
|
redirectUri: 'http://localhost:4000/auth/strava/callback',
|
||||||
|
accessToken: '',
|
||||||
|
refreshToken: '',
|
||||||
|
tokenExpiresAt: null as number | null,
|
||||||
|
},
|
||||||
|
riot: {
|
||||||
|
region: 'eun1',
|
||||||
|
summonerNames: {
|
||||||
|
lol: 'R4K4N1',
|
||||||
|
tft: 'R4K4N1',
|
||||||
|
},
|
||||||
|
tagLine: 'EUNE',
|
||||||
|
},
|
||||||
|
faceit: {
|
||||||
|
userId: 'rakani',
|
||||||
|
},
|
||||||
|
tracker: {
|
||||||
|
rocketLeague: {
|
||||||
|
platform: 'steam',
|
||||||
|
username: 'rakani32',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
goals: {
|
||||||
|
sport: {
|
||||||
|
bike: 7500,
|
||||||
|
run: 2500,
|
||||||
|
swim: 250,
|
||||||
|
},
|
||||||
|
endDate: new Date('2026-12-31'),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
placeholder
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Goals Tracker 2026</title>
|
||||||
|
<base href="/">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export { AppServerModule as default } from './app/app.server';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { AppComponent } from './app/app.component';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
|
||||||
|
bootstrapApplication(AppComponent, appConfig).catch((err) =>
|
||||||
|
console.error(err)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||||
|
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global Animations */
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-shimmer {
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float {
|
||||||
|
animation: float 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth scrolling */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(to bottom, #3b82f6, #8b5cf6);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: linear-gradient(to bottom, #2563eb, #7c3aed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide default cursor globally - only on desktop */
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
* {
|
||||||
|
cursor: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
a, button, [role="button"], input, textarea, select {
|
||||||
|
cursor: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./src/**/*.{html,ts}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"files": ["src/main.ts"],
|
||||||
|
"include": ["src/**/*.d.ts"],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"node_modules/**/*.ts",
|
||||||
|
"node_modules/rocketleaguetrackerapi/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": false,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"lib": ["ES2022", "dom"]
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.app.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/server",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"files": ["src/main.server.ts", "server.ts"],
|
||||||
|
"include": ["src/**/*.d.ts"],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"node_modules/**/*.ts",
|
||||||
|
"node_modules/rocketleaguetrackerapi/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": ["jest", "node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user