more seeds

This commit is contained in:
2025-12-27 11:58:35 +01:00
parent 91612e4256
commit 3efa978ee0
25 changed files with 806 additions and 55 deletions

View File

@@ -105,12 +105,12 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
<div className="relative h-full rounded-xl bg-iron-gray border border-charcoal-outline overflow-hidden transition-all duration-200 hover:border-primary-blue/50 hover:shadow-[0_0_30px_rgba(25,140,255,0.15)] hover:bg-iron-gray/80">
{/* Cover Image */}
<div className="relative h-32 overflow-hidden">
<Image
<img
src={coverUrl}
alt={`${league.name} cover`}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
className="absolute inset-0 h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
decoding="async"
/>
{/* Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-iron-gray via-iron-gray/60 to-transparent" />
@@ -141,12 +141,14 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
{/* Logo */}
<div className="absolute left-4 -bottom-6 z-10">
<div className="w-12 h-12 rounded-lg overflow-hidden border-2 border-iron-gray bg-deep-graphite shadow-xl">
<Image
<img
src={logoUrl}
alt={`${league.name} logo`}
width={48}
height={48}
className="w-full h-full object-cover"
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
</div>
</div>

View File

@@ -37,12 +37,14 @@ export default function LeagueHeader({
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-xl overflow-hidden border-2 border-charcoal-outline bg-iron-gray shadow-lg">
<Image
<img
src={logoUrl}
alt={`${leagueName} logo`}
width={64}
height={64}
className="w-full h-full object-cover"
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
</div>
<div>

View File

@@ -11,6 +11,7 @@ import type { RaceDTO } from '../../types/generated/RaceDTO';
import type { GetLeagueAdminConfigOutputDTO } from '../../types/generated/GetLeagueAdminConfigOutputDTO';
import type { LeagueScoringPresetDTO } from '../../types/generated/LeagueScoringPresetDTO';
import type { LeagueSeasonSummaryDTO } from '../../types/generated/LeagueSeasonSummaryDTO';
import type { AllLeaguesWithCapacityAndScoringDTO } from '../../types/AllLeaguesWithCapacityAndScoringDTO';
/**
* Leagues API Client
@@ -23,6 +24,11 @@ export class LeaguesApiClient extends BaseApiClient {
return this.get<AllLeaguesWithCapacityDTO>('/leagues/all-with-capacity');
}
/** Get all leagues with capacity + scoring summary (for leagues page filters) */
getAllWithCapacityAndScoring(): Promise<AllLeaguesWithCapacityAndScoringDTO> {
return this.get<AllLeaguesWithCapacityAndScoringDTO>('/leagues/all-with-capacity-and-scoring');
}
/** Get total number of leagues */
getTotal(): Promise<TotalLeaguesDTO> {
return this.get<TotalLeaguesDTO>('/leagues/total-leagues');

View File

@@ -18,6 +18,7 @@ describe('LeagueService', () => {
beforeEach(() => {
mockApiClient = {
getAllWithCapacity: vi.fn(),
getAllWithCapacityAndScoring: vi.fn(),
getStandings: vi.fn(),
getTotal: vi.fn(),
getSchedule: vi.fn(),
@@ -30,7 +31,7 @@ describe('LeagueService', () => {
});
describe('getAllLeagues', () => {
it('should call apiClient.getAllWithCapacity and return array of LeagueSummaryViewModel', async () => {
it('should call apiClient.getAllWithCapacityAndScoring and return array of LeagueSummaryViewModel', async () => {
const mockDto = {
totalCount: 2,
leagues: [
@@ -39,11 +40,11 @@ describe('LeagueService', () => {
],
} as any;
mockApiClient.getAllWithCapacity.mockResolvedValue(mockDto);
mockApiClient.getAllWithCapacityAndScoring.mockResolvedValue(mockDto);
const result = await service.getAllLeagues();
expect(mockApiClient.getAllWithCapacity).toHaveBeenCalled();
expect(mockApiClient.getAllWithCapacityAndScoring).toHaveBeenCalled();
expect(result).toHaveLength(2);
expect(result.map((l) => l.id)).toEqual(['league-1', 'league-2']);
});
@@ -51,16 +52,16 @@ describe('LeagueService', () => {
it('should handle empty leagues array', async () => {
const mockDto = { totalCount: 0, leagues: [] } as any;
mockApiClient.getAllWithCapacity.mockResolvedValue(mockDto);
mockApiClient.getAllWithCapacityAndScoring.mockResolvedValue(mockDto);
const result = await service.getAllLeagues();
expect(result).toHaveLength(0);
});
it('should throw error when apiClient.getAllWithCapacity fails', async () => {
it('should throw error when apiClient.getAllWithCapacityAndScoring fails', async () => {
const error = new Error('API call failed');
mockApiClient.getAllWithCapacity.mockRejectedValue(error);
mockApiClient.getAllWithCapacityAndScoring.mockRejectedValue(error);
await expect(service.getAllLeagues()).rejects.toThrow('API call failed');
});

View File

@@ -44,17 +44,20 @@ export class LeagueService {
* Get all leagues with view model transformation
*/
async getAllLeagues(): Promise<LeagueSummaryViewModel[]> {
const dto = await this.apiClient.getAllWithCapacity();
return dto.leagues.map((league: LeagueWithCapacityDTO) => ({
const dto = await this.apiClient.getAllWithCapacityAndScoring();
return dto.leagues.map((league) => ({
id: league.id,
name: league.name,
description: (league as any).description ?? '',
ownerId: (league as any).ownerId ?? '',
createdAt: (league as any).createdAt ?? '',
maxDrivers: (league as any).settings?.maxDrivers ?? 0,
usedDriverSlots: (league as any).usedSlots ?? 0,
structureSummary: 'TBD',
timingSummary: 'TBD'
description: league.description,
ownerId: league.ownerId,
createdAt: league.createdAt,
maxDrivers: league.settings?.maxDrivers ?? 0,
usedDriverSlots: league.usedSlots ?? 0,
structureSummary: league.scoring?.scoringPresetName ?? 'Custom rules',
scoringPatternSummary: league.scoring?.scoringPatternSummary,
timingSummary: league.timingSummary ?? '',
...(league.scoring ? { scoring: league.scoring } : {}),
}));
}
@@ -162,16 +165,16 @@ export class LeagueService {
// For now, assume league data comes from getAllWithCapacity or a new endpoint
// Since API may not have detailed league, we'll mock or assume
// In real implementation, add getLeagueDetail to API
const allLeagues = await this.apiClient.getAllWithCapacity();
const leagueDto = allLeagues.leagues.find((l: any) => l.id === leagueId);
const allLeagues = await this.apiClient.getAllWithCapacityAndScoring();
const leagueDto = allLeagues.leagues.find((l) => l.id === leagueId);
if (!leagueDto) return null;
// LeagueWithCapacityDTO already carries core fields; fall back to placeholder description/owner when not provided
const league = {
id: leagueDto.id,
name: leagueDto.name,
description: (leagueDto as any).description ?? 'Description not available',
ownerId: (leagueDto as any).ownerId ?? 'owner-id',
description: leagueDto.description ?? 'Description not available',
ownerId: leagueDto.ownerId ?? 'owner-id',
};
// Get owner
@@ -245,12 +248,12 @@ export class LeagueService {
try {
// Get league basic info
const allLeagues = await this.apiClient.getAllWithCapacity();
const league = allLeagues.leagues.find((l: any) => l.id === leagueId);
const allLeagues = await this.apiClient.getAllWithCapacityAndScoring();
const league = allLeagues.leagues.find((l) => l.id === leagueId);
if (!league) return null;
// Get owner
const owner = await this.driversApiClient.getDriver((league as any).ownerId);
const owner = await this.driversApiClient.getDriver(league.ownerId);
// League scoring configuration is not exposed separately yet; use null to indicate "not configured" in the UI
const scoringConfig: LeagueScoringConfigDTO | null = null;

View File

@@ -0,0 +1,45 @@
export type LeagueCapacityAndScoringPrimaryChampionshipType =
| 'driver'
| 'team'
| 'nations'
| 'trophy';
export type LeagueCapacityAndScoringSummaryScoringDTO = {
gameId: string;
gameName: string;
primaryChampionshipType: LeagueCapacityAndScoringPrimaryChampionshipType;
scoringPresetId: string;
scoringPresetName: string;
dropPolicySummary: string;
scoringPatternSummary: string;
};
export type LeagueCapacityAndScoringSocialLinksDTO = {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
};
export type LeagueCapacityAndScoringSettingsDTO = {
maxDrivers: number;
sessionDuration?: number;
qualifyingFormat?: string;
};
export type LeagueWithCapacityAndScoringDTO = {
id: string;
name: string;
description: string;
ownerId: string;
createdAt: string;
settings: LeagueCapacityAndScoringSettingsDTO;
usedSlots: number;
socialLinks?: LeagueCapacityAndScoringSocialLinksDTO;
scoring?: LeagueCapacityAndScoringSummaryScoringDTO;
timingSummary?: string;
};
export type AllLeaguesWithCapacityAndScoringDTO = {
leagues: LeagueWithCapacityAndScoringDTO[];
totalCount: number;
};

View File

@@ -20,6 +20,24 @@ const nextConfig = {
hostname: 'picsum.photos',
},
],
dangerouslyAllowSVG: true,
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
contentDispositionType: 'inline',
},
async rewrites() {
const rawBaseUrl =
process.env.API_BASE_URL ??
process.env.NEXT_PUBLIC_API_BASE_URL ??
'http://localhost:3001';
const baseUrl = rawBaseUrl.endsWith('/') ? rawBaseUrl.slice(0, -1) : rawBaseUrl;
return [
{
source: '/api/media/:path*',
destination: `${baseUrl}/media/:path*`,
},
];
},
typescript: {
ignoreBuildErrors: false,