generated from bisco/codex-bootstrap
feat: bootstrap HoopScout scouting app
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.angular
|
||||
npm-debug.log*
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM node:22.16.0-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 4200
|
||||
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"hoopscout-frontend": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/hoopscout-frontend",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": ["zone.js"],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [],
|
||||
"styles": ["src/styles.css"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "600kB",
|
||||
"maximumError": "1MB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "hoopscout-frontend:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "hoopscout-frontend:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+11995
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "hoopscout-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "ng serve --host 0.0.0.0 --port 4200",
|
||||
"build": "ng build",
|
||||
"test": "tsx --import ./src/test-setup.ts --test \"src/**/*.spec.ts\"",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "21.2.14",
|
||||
"@angular/common": "21.2.14",
|
||||
"@angular/compiler": "21.2.14",
|
||||
"@angular/core": "21.2.14",
|
||||
"@angular/forms": "21.2.14",
|
||||
"@angular/platform-browser": "21.2.14",
|
||||
"@angular/router": "21.2.14",
|
||||
"rxjs": "7.8.2",
|
||||
"tslib": "2.8.1",
|
||||
"zone.js": "0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "21.2.14",
|
||||
"@angular/cli": "21.2.14",
|
||||
"@angular/compiler-cli": "21.2.14",
|
||||
"@types/node": "22.15.30",
|
||||
"tsx": "4.20.3",
|
||||
"typescript": "5.9.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { buildPlayerSearchParams } from './player-api.service';
|
||||
|
||||
describe('buildPlayerSearchParams', () => {
|
||||
it('keeps only populated filters and maps stat ranges to API lookups', () => {
|
||||
const params = buildPlayerSearchParams({
|
||||
q: 'luca',
|
||||
position: 'PG',
|
||||
role: '',
|
||||
league: 'LBA',
|
||||
minPoints: 15,
|
||||
minAssists: 5,
|
||||
minRebounds: null,
|
||||
minEfficiency: 18,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
params.toString(),
|
||||
'q=luca&position=PG&league=LBA&points_per_game__gte=15&assists_per_game__gte=5&efficiency_rating__gte=18',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { PlayerFilters, PlayerSearchResponse } from '../models';
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
export function buildPlayerSearchParams(filters: PlayerFilters): HttpParams {
|
||||
let params = new HttpParams();
|
||||
|
||||
const textFilters: Array<[string, string]> = [
|
||||
['q', filters.q],
|
||||
['position', filters.position],
|
||||
['role', filters.role],
|
||||
['league', filters.league],
|
||||
];
|
||||
|
||||
for (const [key, value] of textFilters) {
|
||||
if (value.trim().length > 0) {
|
||||
params = params.set(key, value.trim());
|
||||
}
|
||||
}
|
||||
|
||||
const statFilters: Array<[string, number | null]> = [
|
||||
['points_per_game__gte', filters.minPoints],
|
||||
['assists_per_game__gte', filters.minAssists],
|
||||
['rebounds_per_game__gte', filters.minRebounds],
|
||||
['efficiency_rating__gte', filters.minEfficiency],
|
||||
];
|
||||
|
||||
for (const [key, value] of statFilters) {
|
||||
if (value !== null && Number.isFinite(value)) {
|
||||
params = params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlayerApiService {
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
searchPlayers(filters: PlayerFilters): Observable<PlayerSearchResponse> {
|
||||
return this.http.get<PlayerSearchResponse>(`${API_BASE_URL}/players/`, {
|
||||
params: buildPlayerSearchParams(filters),
|
||||
withCredentials: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
.shell {
|
||||
width: min(1500px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 24px 0 32px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2rem, 4vw, 4rem);
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
min-width: 112px;
|
||||
padding: 12px 16px;
|
||||
color: white;
|
||||
background: var(--accent-strong);
|
||||
border-radius: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.status span {
|
||||
display: block;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status small {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1.7fr) repeat(7, minmax(110px, 1fr)) auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
padding: 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 0 10px;
|
||||
color: var(--ink);
|
||||
background: #fbfcfa;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
border-color: var(--accent);
|
||||
outline: 2px solid rgba(23, 107, 100, 0.16);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.primary {
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
color: var(--accent-strong);
|
||||
background: #e7eeeb;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin: 14px 0 0;
|
||||
padding: 12px 14px;
|
||||
color: #7a1d17;
|
||||
background: #fff1ee;
|
||||
border: 1px solid #f2bbb2;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.table-wrap,
|
||||
.detail {
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 13px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
background: #f8faf7;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tbody tr:hover,
|
||||
tbody tr.selected {
|
||||
background: #eef6f3;
|
||||
}
|
||||
|
||||
td strong,
|
||||
td small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
td small {
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 28px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.role {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.bio-grid,
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bio-grid div,
|
||||
.metric-grid div {
|
||||
min-height: 82px;
|
||||
padding: 12px;
|
||||
background: #f8faf7;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
dt,
|
||||
.metric-grid span {
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 6px 0 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-grid strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--accent-strong);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.filters {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-column: span 4;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.shell {
|
||||
width: min(100vw - 20px, 1500px);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.filters {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 820px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Private basketball scouting</p>
|
||||
<h1>HoopScout</h1>
|
||||
</div>
|
||||
<div class="status">
|
||||
<span>{{ resultCount }}</span>
|
||||
<small>matches</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filters" aria-label="Player filters">
|
||||
<label>
|
||||
Search
|
||||
<input type="search" [(ngModel)]="filters.q" placeholder="Name, role, team, nationality">
|
||||
</label>
|
||||
<label>
|
||||
Position
|
||||
<select [(ngModel)]="filters.position">
|
||||
<option *ngFor="let position of positions" [value]="position">{{ position || 'Any' }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Role
|
||||
<input type="text" [(ngModel)]="filters.role" placeholder="3 and D, handler, rim runner">
|
||||
</label>
|
||||
<label>
|
||||
League
|
||||
<select [(ngModel)]="filters.league">
|
||||
<option *ngFor="let league of leagues" [value]="league">{{ league || 'Any' }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Min PPG
|
||||
<input type="number" [(ngModel)]="filters.minPoints" min="0" step="0.1">
|
||||
</label>
|
||||
<label>
|
||||
Min APG
|
||||
<input type="number" [(ngModel)]="filters.minAssists" min="0" step="0.1">
|
||||
</label>
|
||||
<label>
|
||||
Min RPG
|
||||
<input type="number" [(ngModel)]="filters.minRebounds" min="0" step="0.1">
|
||||
</label>
|
||||
<label>
|
||||
Min EFF
|
||||
<input type="number" [(ngModel)]="filters.minEfficiency" min="0" step="0.1">
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="button" class="primary" (click)="search()">Search</button>
|
||||
<button type="button" class="secondary" (click)="clearFilters()">Reset</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p *ngIf="errorMessage" class="alert">{{ errorMessage }}</p>
|
||||
|
||||
<section class="workspace" aria-label="Scouting workspace">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Pos</th>
|
||||
<th>League</th>
|
||||
<th>Team</th>
|
||||
<th>PPG</th>
|
||||
<th>APG</th>
|
||||
<th>RPG</th>
|
||||
<th>EFF</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
*ngFor="let player of players"
|
||||
[class.selected]="selectedPlayer?.id === player.id"
|
||||
(click)="selectPlayer(player)"
|
||||
>
|
||||
<td>
|
||||
<strong>{{ player.name }}</strong>
|
||||
<small>{{ player.nationality || '-' }} · {{ player.height_cm || '-' }} cm · {{ player.weight_kg || '-' }} kg</small>
|
||||
</td>
|
||||
<td>{{ player.position }}</td>
|
||||
<td>{{ player.league?.code || '-' }}</td>
|
||||
<td>{{ player.team?.name || '-' }}</td>
|
||||
<td>{{ statValue(player, 'points_per_game') }}</td>
|
||||
<td>{{ statValue(player, 'assists_per_game') }}</td>
|
||||
<td>{{ statValue(player, 'rebounds_per_game') }}</td>
|
||||
<td>{{ statValue(player, 'efficiency_rating') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div *ngIf="!loading && players.length === 0" class="empty">No players match the current filters.</div>
|
||||
<div *ngIf="loading" class="empty">Loading scouting board...</div>
|
||||
</div>
|
||||
|
||||
<aside class="detail" *ngIf="selectedPlayer">
|
||||
<div>
|
||||
<p class="eyebrow">Selected profile</p>
|
||||
<h2>{{ selectedPlayer.name }}</h2>
|
||||
<p class="role">{{ selectedPlayer.position }} · {{ selectedPlayer.role || 'Role pending' }}</p>
|
||||
</div>
|
||||
<dl class="bio-grid">
|
||||
<div>
|
||||
<dt>League</dt>
|
||||
<dd>{{ selectedPlayer.league?.name || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Team</dt>
|
||||
<dd>{{ selectedPlayer.team?.name || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Born</dt>
|
||||
<dd>{{ selectedPlayer.birth_year || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Size</dt>
|
||||
<dd>{{ selectedPlayer.height_cm || '-' }} cm / {{ selectedPlayer.weight_kg || '-' }} kg</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="metric-grid">
|
||||
<div>
|
||||
<span>PPG</span>
|
||||
<strong>{{ statValue(selectedPlayer, 'points_per_game') }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>TS%</span>
|
||||
<strong>{{ statValue(selectedPlayer, 'true_shooting_percentage') }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>USG%</span>
|
||||
<strong>{{ statValue(selectedPlayer, 'usage_percentage') }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Games</span>
|
||||
<strong>{{ statValue(selectedPlayer, 'games_played') }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { PlayerApiService } from './api/player-api.service';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
it('loads players through the API service', () => {
|
||||
const api = {
|
||||
searchPlayers: () =>
|
||||
of({
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Luca Marini',
|
||||
position: 'PG',
|
||||
role: 'Primary ball handler',
|
||||
birth_year: 2001,
|
||||
height_cm: 190,
|
||||
weight_kg: 86,
|
||||
nationality: 'Italy',
|
||||
league: { name: 'Lega Basket Serie A', code: 'LBA', region: 'Europe', country: 'Italy' },
|
||||
team: { name: 'Milano', country: 'Italy' },
|
||||
stats: {
|
||||
games_played: 28,
|
||||
minutes_per_game: '29.40',
|
||||
points_per_game: '16.80',
|
||||
assists_per_game: '6.20',
|
||||
rebounds_per_game: '3.80',
|
||||
efficiency_rating: '20.50',
|
||||
true_shooting_percentage: '59.20',
|
||||
usage_percentage: '25.00',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as PlayerApiService;
|
||||
const component = new AppComponent(api);
|
||||
|
||||
component.search();
|
||||
|
||||
assert.equal(component.players.length, 1);
|
||||
assert.equal(component.players[0].name, 'Luca Marini');
|
||||
assert.equal(component.resultCount, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { PlayerApiService } from './api/player-api.service';
|
||||
import { PlayerFilters, PlayerSummary } from './models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.css',
|
||||
})
|
||||
export class AppComponent {
|
||||
readonly positions = ['', 'PG', 'SG', 'SF', 'PF', 'C'];
|
||||
readonly leagues = ['', 'LBA', 'ACB', 'ABA', 'BBL', 'BSL', 'LNB'];
|
||||
|
||||
filters: PlayerFilters = {
|
||||
q: '',
|
||||
position: '',
|
||||
role: '',
|
||||
league: '',
|
||||
minPoints: null,
|
||||
minAssists: null,
|
||||
minRebounds: null,
|
||||
minEfficiency: null,
|
||||
};
|
||||
players: PlayerSummary[] = [];
|
||||
selectedPlayer: PlayerSummary | null = null;
|
||||
resultCount = 0;
|
||||
loading = false;
|
||||
errorMessage = '';
|
||||
|
||||
constructor(private readonly playerApi: PlayerApiService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.search();
|
||||
}
|
||||
|
||||
search(): void {
|
||||
this.loading = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
this.playerApi.searchPlayers(this.filters).subscribe({
|
||||
next: (response) => {
|
||||
this.players = response.results;
|
||||
this.resultCount = response.count;
|
||||
this.selectedPlayer = this.players[0] ?? null;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.errorMessage = 'Sign in is required or the scouting API is unavailable.';
|
||||
this.loading = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.filters = {
|
||||
q: '',
|
||||
position: '',
|
||||
role: '',
|
||||
league: '',
|
||||
minPoints: null,
|
||||
minAssists: null,
|
||||
minRebounds: null,
|
||||
minEfficiency: null,
|
||||
};
|
||||
this.search();
|
||||
}
|
||||
|
||||
selectPlayer(player: PlayerSummary): void {
|
||||
this.selectedPlayer = player;
|
||||
}
|
||||
|
||||
statValue(player: PlayerSummary, key: keyof NonNullable<PlayerSummary['stats']>): string {
|
||||
const value = player.stats?.[key];
|
||||
return value === undefined || value === null ? '-' : String(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export interface League {
|
||||
name: string;
|
||||
code: string;
|
||||
region: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
name: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export interface PlayerStats {
|
||||
games_played: number;
|
||||
minutes_per_game: string;
|
||||
points_per_game: string;
|
||||
assists_per_game: string;
|
||||
rebounds_per_game: string;
|
||||
efficiency_rating: string;
|
||||
true_shooting_percentage: string;
|
||||
usage_percentage: string;
|
||||
}
|
||||
|
||||
export interface PlayerSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
position: string;
|
||||
role: string;
|
||||
birth_year: number | null;
|
||||
height_cm: number | null;
|
||||
weight_kg: number | null;
|
||||
nationality: string;
|
||||
league: League | null;
|
||||
team: Team | null;
|
||||
stats: PlayerStats | null;
|
||||
}
|
||||
|
||||
export interface PlayerSearchResponse {
|
||||
count: number;
|
||||
results: PlayerSummary[];
|
||||
}
|
||||
|
||||
export interface PlayerFilters {
|
||||
q: string;
|
||||
position: string;
|
||||
role: string;
|
||||
league: string;
|
||||
minPoints: number | null;
|
||||
minAssists: number | null;
|
||||
minRebounds: number | null;
|
||||
minEfficiency: number | null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>HoopScout</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { provideHttpClient, withXsrfConfiguration } from '@angular/common/http';
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
provideAnimations(),
|
||||
provideHttpClient(
|
||||
withXsrfConfiguration({
|
||||
cookieName: 'csrftoken',
|
||||
headerName: 'X-CSRFToken',
|
||||
}),
|
||||
),
|
||||
],
|
||||
}).catch((error) => console.error(error));
|
||||
@@ -0,0 +1,30 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f3f5f1;
|
||||
--ink: #17211f;
|
||||
--muted: #66706c;
|
||||
--panel: #ffffff;
|
||||
--line: #d9dfd9;
|
||||
--accent: #176b64;
|
||||
--accent-strong: #0d4d48;
|
||||
--gold: #bd8b2f;
|
||||
--red: #a4473f;
|
||||
--shadow: 0 18px 50px rgba(25, 38, 35, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import '@angular/compiler';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": ["src/main.ts"],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "./dist/out-tsc",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"downlevelIteration": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": ["ES2022", "dom"]
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user