harden media

This commit is contained in:
2025-12-31 15:39:28 +01:00
parent 92226800df
commit 8260bf7baf
413 changed files with 8361 additions and 1544 deletions

View File

@@ -33,6 +33,7 @@ export default function DriverCard(props: DriverCardProps) {
const driverViewModel = new DriverViewModel({
id,
name,
avatarUrl: null,
});
return (

View File

@@ -1,5 +1,6 @@
import Link from 'next/link';
import Image from 'next/image';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
export interface DriverIdentityProps {
@@ -21,8 +22,8 @@ export default function DriverIdentity(props: DriverIdentityProps) {
const metaTextClasses = 'text-xs md:text-sm text-gray-400';
// Use provided avatar URL or fallback to default avatar path
const avatarUrl = driver.avatarUrl || `/api/media/avatar/${driver.id}`;
// Use provided avatar URL or show placeholder if null
const avatarUrl = driver.avatarUrl;
const content = (
<div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
@@ -30,13 +31,17 @@ export default function DriverIdentity(props: DriverIdentityProps) {
className={`rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center shrink-0`}
style={{ width: avatarSize, height: avatarSize }}
>
<Image
src={avatarUrl}
alt={driver.name}
width={avatarSize}
height={avatarSize}
className="w-full h-full object-cover"
/>
{avatarUrl ? (
<Image
src={avatarUrl}
alt={driver.name}
width={avatarSize}
height={avatarSize}
className="w-full h-full object-cover"
/>
) : (
<PlaceholderImage size={avatarSize} />
)}
</div>
<div className="flex-1 min-w-0">

View File

@@ -1,8 +1,10 @@
import React from 'react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { Users, Crown, Shield, ChevronRight } from 'lucide-react';
import Button from '@/components/ui/Button';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
interface TeamLeaderboardPreviewProps {
teams: TeamSummaryViewModel[];
@@ -82,9 +84,15 @@ export default function TeamLeaderboardPreview({ teams, onTeamClick }: TeamLeade
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
</div>
{/* Team Icon */}
<div className={`flex h-9 w-9 items-center justify-center rounded-lg ${levelConfig?.bgColor} border ${levelConfig?.borderColor}`}>
<LevelIcon className={`w-4 h-4 ${levelConfig?.color}`} />
{/* Team Logo */}
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-charcoal-outline border border-charcoal-outline overflow-hidden">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={36}
height={36}
className="w-full h-full object-cover"
/>
</div>
{/* Info */}

View File

@@ -14,7 +14,8 @@ import {
} from 'lucide-react';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
import { useServices } from '@/lib/services/ServiceProvider';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
import { getMediaUrl } from '@/lib/utilities/media';
interface LeagueCardProps {
league: LeagueSummaryViewModel;
@@ -114,9 +115,8 @@ function isNewLeague(createdAt: string | Date): boolean {
}
export default function LeagueCard({ league, onClick }: LeagueCardProps) {
const { mediaService } = useServices();
const coverUrl = mediaService.getLeagueCover(league.id);
const logoUrl = mediaService.getLeagueLogo(league.id);
const coverUrl = getMediaUrl('league-cover', league.id);
const logoUrl = league.logoUrl;
const ChampionshipIcon = getChampionshipIcon(league.scoring?.primaryChampionshipType);
const championshipLabel = getChampionshipLabel(league.scoring?.primaryChampionshipType);
@@ -190,15 +190,19 @@ 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">
<img
src={logoUrl}
alt={`${league.name} logo`}
width={48}
height={48}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
{logoUrl ? (
<img
src={logoUrl}
alt={`${league.name} logo`}
width={48}
height={48}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
<PlaceholderImage size={48} />
)}
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
'use client';
import MembershipStatus from '@/components/leagues/MembershipStatus';
import { useServices } from '@/lib/services/ServiceProvider';
import { getMediaUrl } from '@/lib/utilities/media';
import Image from 'next/image';
@@ -28,8 +28,7 @@ export default function LeagueHeader({
ownerId,
mainSponsor,
}: LeagueHeaderProps) {
const { mediaService } = useServices();
const logoUrl = mediaService.getLeagueLogo(leagueId);
const logoUrl = getMediaUrl('league-logo', leagueId);
return (
<div className="mb-8">

View File

@@ -43,7 +43,7 @@ export default function LeagueMembers({
const byId: Record<string, DriverViewModel> = {};
for (const dto of driverDtos) {
byId[dto.id] = new DriverViewModel(dto);
byId[dto.id] = new DriverViewModel({ ...dto, avatarUrl: (dto as any).avatarUrl ?? null });
}
setDriversById(byId);
} else {

View File

@@ -48,6 +48,7 @@ export default function LeagueOwnershipTransfer({
driver={new DriverViewModel({
id: ownerSummary.driver.id,
name: ownerSummary.driver.name,
avatarUrl: (ownerSummary.driver as any).avatarUrl ?? null,
iracingId: ownerSummary.driver.iracingId,
country: ownerSummary.driver.country,
bio: ownerSummary.driver.bio,

View File

@@ -8,7 +8,8 @@ import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import { LeagueRoleDisplay } from '@/lib/display-objects/LeagueRoleDisplay';
import CountryFlag from '@/components/ui/CountryFlag';
import { useServices } from '@/lib/services/ServiceProvider';
import { getMediaUrl } from '@/lib/utilities/media';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
// Position background colors
const getPositionBgColor = (position: number): string => {
@@ -52,7 +53,6 @@ export default function StandingsTable({
onRemoveMember,
onUpdateRole
}: StandingsTableProps) {
const { mediaService } = useServices();
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
const [activeMenu, setActiveMenu] = useState<{ driverId: string; type: 'member' | 'points' } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
@@ -320,16 +320,20 @@ export default function StandingsTable({
{/* Avatar */}
<div className="relative">
<div className="w-10 h-10 rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center shrink-0">
{driver && (
<Image
src={mediaService.getDriverAvatar(driver.id)}
alt={driver.name}
width={40}
height={40}
className="w-full h-full object-cover"
/>
)}
</div>
{driver && (
driver.avatarUrl ? (
<Image
src={driver.avatarUrl}
alt={driver.name}
width={40}
height={40}
className="w-full h-full object-cover"
/>
) : (
<PlaceholderImage size={40} />
)
)}
</div>
{/* Nationality flag */}
{driver && driver.country && (
<div className="absolute -bottom-1 -right-1">

View File

@@ -5,13 +5,13 @@ import Image from 'next/image';
import Link from 'next/link';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import DriverRating from '@/components/profile/DriverRatingPill';
import { useServices } from '@/lib/services/ServiceProvider';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
export interface DriverSummaryPillProps {
driver: DriverViewModel;
rating: number | null;
rank: number | null;
avatarSrc?: string;
avatarSrc?: string | null;
onClick?: () => void;
href?: string;
}
@@ -19,21 +19,22 @@ export interface DriverSummaryPillProps {
export default function DriverSummaryPill(props: DriverSummaryPillProps) {
const { driver, rating, rank, avatarSrc, onClick, href } = props;
const { mediaService } = useServices();
const resolvedAvatar =
avatarSrc ?? mediaService.getDriverAvatar(driver.id);
const resolvedAvatar = avatarSrc;
const content = (
<>
<div className="w-8 h-8 rounded-full overflow-hidden bg-charcoal-outline flex items-center justify-center border border-charcoal-outline/80">
<Image
src={resolvedAvatar}
alt={driver.name}
width={32}
height={32}
className="w-full h-full object-cover"
/>
{resolvedAvatar ? (
<Image
src={resolvedAvatar}
alt={driver.name}
width={32}
height={32}
className="w-full h-full object-cover"
/>
) : (
<PlaceholderImage size={32} />
)}
</div>
<div className="flex flex-col leading-tight text-left">

View File

@@ -5,7 +5,7 @@ import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import Button from '../ui/Button';
import DriverRatingPill from '@/components/profile/DriverRatingPill';
import CountryFlag from '@/components/ui/CountryFlag';
import { useServices } from '@/lib/services/ServiceProvider';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
interface ProfileHeaderProps {
driver: DriverViewModel;
@@ -26,19 +26,21 @@ export default function ProfileHeader({
teamName,
teamTag,
}: ProfileHeaderProps) {
const { mediaService } = useServices();
return (
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary-blue to-purple-600 overflow-hidden flex items-center justify-center">
<Image
src={mediaService.getDriverAvatar(driver.id)}
alt={driver.name}
width={80}
height={80}
className="w-full h-full object-cover"
/>
{driver.avatarUrl ? (
<Image
src={driver.avatarUrl}
alt={driver.name}
width={80}
height={80}
className="w-full h-full object-cover"
/>
) : (
<PlaceholderImage size={80} />
)}
</div>
<div>

View File

@@ -19,9 +19,8 @@ vi.mock('@/hooks/useEffectiveDriverId', () => {
};
});
// Mock services hook to inject stub driverService/mediaService
const mockFindById = vi.fn<[], Promise<DriverDTO | null>>();
const mockGetDriverAvatar = vi.fn<(driverId: string) => string>();
// Mock services hook to inject stub driverService
const mockFindById = vi.fn();
vi.mock('@/lib/services/ServiceProvider', () => {
return {
@@ -30,7 +29,7 @@ vi.mock('@/lib/services/ServiceProvider', () => {
findById: mockFindById,
},
mediaService: {
getDriverAvatar: mockGetDriverAvatar,
getDriverAvatar: vi.fn(),
},
}),
};
@@ -66,7 +65,6 @@ describe('UserPill', () => {
mockedAuthValue = { session: null };
mockedDriverId = null;
mockFindById.mockReset();
mockGetDriverAvatar.mockReset();
});
it('renders auth links when there is no session', () => {
@@ -94,19 +92,19 @@ describe('UserPill', () => {
expect(mockFindById).not.toHaveBeenCalled();
});
it('loads driver via driverService and uses mediaService avatar', async () => {
it('loads driver via driverService and uses driver avatarUrl', async () => {
const driver: DriverDTO = {
id: 'driver-1',
iracingId: 'ir-123',
name: 'Test Driver',
country: 'DE',
avatarUrl: '/api/media/avatar/driver-1',
};
mockedAuthValue = { session: { user: { id: 'user-1' } } };
mockedDriverId = driver.id;
mockFindById.mockResolvedValue(driver);
mockGetDriverAvatar.mockImplementation((driverId: string) => `/api/media/avatar/${driverId}`);
render(<UserPill />);
@@ -115,6 +113,5 @@ describe('UserPill', () => {
});
expect(mockFindById).toHaveBeenCalledWith('driver-1');
expect(mockGetDriverAvatar).toHaveBeenCalledWith('driver-1');
});
});

View File

@@ -106,7 +106,7 @@ export default function UserPill() {
const dto = await driverService.findById(primaryDriverId);
if (!cancelled) {
setDriver(dto ? new DriverViewModelClass(dto) : null);
setDriver(dto ? new DriverViewModelClass({ ...dto, avatarUrl: (dto as any).avatarUrl ?? null }) : null);
}
}
@@ -127,7 +127,7 @@ export default function UserPill() {
const rating: number | null = null;
const rank: number | null = null;
const avatarSrc = mediaService.getDriverAvatar(primaryDriverId);
const avatarSrc = driver.avatarUrl;
return {
driver,
@@ -135,7 +135,7 @@ export default function UserPill() {
rating,
rank,
};
}, [session, driver, primaryDriverId, mediaService]);
}, [session, driver, primaryDriverId]);
// Close menu when clicking outside
useEffect(() => {

View File

@@ -1,7 +1,7 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useServices } from '@/lib/services/ServiceProvider';
import { getMediaUrl } from '@/lib/utilities/media';
interface Friend {
id: string;
@@ -23,8 +23,6 @@ function getCountryFlag(countryCode: string): string {
}
export default function FriendPill({ friend }: FriendPillProps) {
const { mediaService } = useServices();
return (
<Link
href={`/drivers/${friend.id}`}
@@ -32,7 +30,7 @@ export default function FriendPill({ friend }: FriendPillProps) {
>
<div className="w-8 h-8 rounded-full overflow-hidden bg-gradient-to-br from-primary-blue to-purple-600">
<Image
src={mediaService.getDriverAvatar(friend.id)}
src={getMediaUrl('driver-avatar', friend.id)}
alt={friend.name}
width={32}
height={32}

View File

@@ -1,5 +1,7 @@
import Image from 'next/image';
import { UserPlus, Users, Trophy } from 'lucide-react';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
const SKILL_LEVELS: {
id: string;
@@ -77,8 +79,14 @@ export default function FeaturedRecruiting({ teams, onTeamClick }: FeaturedRecru
className="p-4 rounded-xl bg-iron-gray/60 border border-charcoal-outline hover:border-performance-green/40 transition-all duration-200 text-left group"
>
<div className="flex items-start justify-between mb-3">
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${levelConfig?.bgColor} border ${levelConfig?.borderColor}`}>
{/* LevelIcon would be here */}
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-charcoal-outline border border-charcoal-outline overflow-hidden">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={32}
height={32}
className="w-full h-full object-cover"
/>
</div>
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] bg-performance-green/10 text-performance-green border border-performance-green/20">
<div className="w-1.5 h-1.5 rounded-full bg-performance-green animate-pulse" />

View File

@@ -17,7 +17,7 @@ import {
Languages,
} from 'lucide-react';
import { useServices } from '@/lib/services/ServiceProvider';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
interface TeamCardProps {
id: string;
@@ -81,8 +81,7 @@ export default function TeamCard({
category,
onClick,
}: TeamCardProps) {
const { mediaService } = useServices();
const logoUrl = logo || mediaService.getTeamLogo(id);
const logoUrl = logo;
const performanceBadge = getPerformanceBadge(performanceLevel);
const specializationBadge = getSpecializationBadge(specialization);
@@ -98,13 +97,17 @@ export default function TeamCard({
<div className="flex items-start gap-4">
{/* Logo */}
<div className="w-14 h-14 rounded-xl bg-charcoal-outline flex items-center justify-center flex-shrink-0 overflow-hidden border border-charcoal-outline">
<Image
src={logoUrl}
alt={name}
width={56}
height={56}
className="w-full h-full object-cover"
/>
{logoUrl ? (
<Image
src={logoUrl}
alt={name}
width={56}
height={56}
className="w-full h-full object-cover"
/>
) : (
<PlaceholderImage size={56} />
)}
</div>
{/* Title & Badges */}

View File

@@ -1,8 +1,8 @@
'use client';
import { useServices } from '@/lib/services/ServiceProvider';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { getMediaUrl } from '@/lib/utilities/media';
export interface TeamLadderRowProps {
rank: number;
@@ -26,8 +26,7 @@ export default function TeamLadderRow({
totalRaces,
}: TeamLadderRowProps) {
const router = useRouter();
const { mediaService } = useServices();
const logo = teamLogoUrl ?? mediaService.getTeamLogo(teamId);
const logo = teamLogoUrl ?? getMediaUrl('team-logo', teamId);
const handleClick = () => {
router.push(`/teams/${teamId}`);

View File

@@ -1,7 +1,9 @@
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { Award, ChevronRight, Crown, Trophy, Users } from 'lucide-react';
import Button from '@/components/ui/Button';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
const SKILL_LEVELS: {
id: string;
@@ -133,8 +135,14 @@ export default function TeamLeaderboardPreview({
</div>
{/* Team Info */}
<div className={`flex h-9 w-9 items-center justify-center rounded-lg ${levelConfig?.bgColor} border ${levelConfig?.borderColor}`}>
{/* LevelIcon */}
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-charcoal-outline border border-charcoal-outline overflow-hidden">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={36}
height={36}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate group-hover:text-purple-400 transition-colors">

View File

@@ -1,5 +1,7 @@
import Image from 'next/image';
import { Trophy, Crown, Users } from 'lucide-react';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
const SKILL_LEVELS: {
id: string;
@@ -128,11 +130,15 @@ export default function TopThreePodium({ teams, onClick }: TopThreePodiumProps)
</div>
)}
{/* Team icon */}
<div
className={`flex h-16 w-16 md:h-20 md:w-20 items-center justify-center rounded-xl ${levelConfig?.bgColor} border ${levelConfig?.borderColor} mb-3`}
>
{/* LevelIcon */}
{/* Team logo */}
<div className="flex h-16 w-16 md:h-20 md:w-20 items-center justify-center rounded-xl bg-charcoal-outline border border-charcoal-outline overflow-hidden mb-3">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={80}
height={80}
className="w-full h-full object-cover"
/>
</div>
{/* Team name */}

View File

@@ -0,0 +1,20 @@
import { User } from 'lucide-react';
export interface PlaceholderImageProps {
size?: number;
className?: string;
}
/**
* Shared placeholder image component for when no avatar/logo URL is available
*/
export default function PlaceholderImage({ size = 48, className = '' }: PlaceholderImageProps) {
return (
<div
className={`rounded-full bg-charcoal-outline flex items-center justify-center ${className}`}
style={{ width: size, height: size }}
>
<User className="w-6 h-6 text-gray-400" />
</div>
);
}