Avatar Resolution System

Overview

PadawanForge implements a sophisticated avatar resolution system that provides multi-source avatar handling with intelligent fallback logic. This system supports custom avatars, OAuth provider avatars, and default generated avatars with automatic validation and optimization.

Architecture

Core Components

  • AvatarResolver Class: Central avatar resolution engine
  • Multi-Source Support: Custom, OAuth, and default avatars
  • R2 Integration: Cloudflare R2 storage for custom avatars
  • Fallback Logic: Intelligent avatar source selection
  • Validation System: Avatar URL validation and health checks

Avatar Sources

  1. Custom Avatars: User-uploaded images stored in R2
  2. OAuth Avatars: Profile pictures from authentication providers
  3. Default Avatars: Generated avatars with user initials

Implementation

AvatarResolver Class

import { AvatarResolver, createAvatarResolver } from '@/lib/utils/avatar-resolver';

// Create avatar resolver instance
const avatarResolver = new AvatarResolver(db, filesBinding, baseUrl);

// Resolve avatar for a player
const avatar = await avatarResolver.resolveAvatar(playerUuid);

console.log('Avatar URL:', avatar.url);
console.log('Source:', avatar.source); // 'custom', 'oauth', or 'default'
console.log('Fallback Initials:', avatar.fallbackInitials);
console.log('Is Valid:', avatar.isValid);

Resolved Avatar Interface

interface ResolvedAvatar {
  url: string;                    // Final avatar URL
  source: 'custom' | 'oauth' | 'default'; // Avatar source
  fallbackInitials: string;       // User initials for fallback
  isValid: boolean;               // Avatar URL validity
}

Usage Examples

Basic Avatar Resolution

import { getPlayerAvatarUrl } from '@/lib/utils/avatar-resolver';

// Get avatar URL for a player
const avatarUrl = await getPlayerAvatarUrl(db, playerUuid, filesBinding, baseUrl);

// Use in component
function PlayerAvatar({ playerUuid }: { playerUuid: string }) {
  const [avatarUrl, setAvatarUrl] = useState<string>('');
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function loadAvatar() {
      try {
        const url = await getPlayerAvatarUrl(db, playerUuid, filesBinding, baseUrl);
        setAvatarUrl(url);
      } catch (error) {
        console.error('Failed to load avatar:', error);
        // Use default avatar
        setAvatarUrl('/default-avatar.png');
      } finally {
        setLoading(false);
      }
    }

    loadAvatar();
  }, [playerUuid]);

  if (loading) return <div className="avatar-skeleton" />;

  return <img src={avatarUrl} alt="Player avatar" className="player-avatar" />;
}

Advanced Avatar Management

import { AvatarResolver } from '@/lib/utils/avatar-resolver';

class AvatarManager {
  private resolver: AvatarResolver;

  constructor(db: D1Database, filesBinding?: R2Bucket, baseUrl?: string) {
    this.resolver = new AvatarResolver(db, filesBinding, baseUrl);
  }

  // Get comprehensive avatar information
  async getAvatarInfo(playerUuid: string) {
    const avatar = await this.resolver.resolveAvatar(playerUuid);
    
    return {
      url: avatar.url,
      source: avatar.source,
      initials: avatar.fallbackInitials,
      isValid: avatar.isValid,
      sizes: await this.resolver.getAvatarSizes(playerUuid)
    };
  }

  // Set custom avatar
  async setCustomAvatar(playerUuid: string, r2Key: string) {
    await this.resolver.setCustomAvatar(playerUuid, r2Key);
  }

  // Remove custom avatar
  async removeCustomAvatar(playerUuid: string) {
    await this.resolver.removeCustomAvatar(playerUuid);
  }

  // Get all available avatar sizes
  async getAvatarSizes(playerUuid: string) {
    return await this.resolver.getAvatarSizes(playerUuid);
  }
}

Avatar Upload Integration

// Handle avatar upload
async function handleAvatarUpload(playerUuid: string, file: File) {
  try {
    // Upload file to R2
    const r2Key = `avatars/${playerUuid}/${Date.now()}-${file.name}`;
    await filesBinding.put(r2Key, file);

    // Set custom avatar
    const avatarManager = new AvatarManager(db, filesBinding, baseUrl);
    await avatarManager.setCustomAvatar(playerUuid, r2Key);

    // Get updated avatar info
    const avatarInfo = await avatarManager.getAvatarInfo(playerUuid);
    
    return avatarInfo;
  } catch (error) {
    console.error('Avatar upload failed:', error);
    throw new Error('Failed to upload avatar');
  }
}

Avatar Sources

Custom Avatars (R2 Storage)

// Custom avatar storage in R2
const customAvatarUrl = avatarResolver.getR2AvatarUrl(playerUuid, '128x128');

// Generate R2 URL
const r2Url = avatarResolver.generateR2Url(r2Key);

// Set custom avatar
await avatarResolver.setCustomAvatar(playerUuid, r2Key);

// Remove custom avatar and files
await avatarResolver.deleteCustomAvatarFiles(playerUuid);

OAuth Provider Avatars

// OAuth avatar handling
interface OAuthAvatarData {
  google?: string;
  discord?: string;
  apple?: string;
  slack?: string;
}

// Avatar resolution priority
// 1. Custom avatar (if exists and valid)
// 2. OAuth avatar (if exists and valid)
// 3. Default generated avatar

Default Generated Avatars

// Generate default avatar with initials
const fallbackInitials = avatarResolver.generateInitials('John Doe'); // "JD"

// Generate default avatar URL
const defaultAvatarUrl = avatarResolver.generateDefaultAvatarUrl(fallbackInitials);

Database Integration

Avatar Data Structure

-- Player avatars resolved view
CREATE VIEW player_avatars_resolved AS
SELECT 
  p.uuid as player_uuid,
  pp.username,
  pp.display_name,
  pp.custom_avatar_url,
  pp.oauth_avatar_url,
  pp.avatar_updated_at,
  CASE 
    WHEN pp.custom_avatar_url IS NOT NULL THEN 'custom'
    WHEN pp.oauth_avatar_url IS NOT NULL THEN 'oauth'
    ELSE 'default'
  END as avatar_source
FROM players p
LEFT JOIN player_profiles pp ON p.uuid = pp.player_uuid;

Avatar Resolution Query

// Get player avatar data from database
const avatarData = await db.prepare(`
  SELECT 
    player_uuid,
    username,
    display_name,
    custom_avatar_url,
    oauth_avatar_url,
    avatar_updated_at,
    avatar_source
  FROM player_avatars_resolved 
  WHERE player_uuid = ?
`).bind(playerUuid).first();

Avatar Validation

URL Validation

// Validate avatar URL
private async validateAvatarUrl(url: string): Promise<boolean> {
  try {
    const response = await fetch(url, { method: 'HEAD' });
    return response.ok && response.headers.get('content-type')?.startsWith('image/');
  } catch {
    return false;
  }
}

Size Validation

// Check available avatar sizes
async getAvatarSizes(playerUuid: string): Promise<string[]> {
  const sizes = ['original', '64x64', '128x128', '256x256'];
  const availableSizes: string[] = [];

  for (const size of sizes) {
    const url = this.getR2AvatarUrl(playerUuid, size as any);
    if (await this.validateAvatarUrl(url)) {
      availableSizes.push(size);
    }
  }

  return availableSizes;
}

R2 Integration

File Storage

// Upload avatar to R2
async uploadAvatar(playerUuid: string, file: File): Promise<string> {
  const r2Key = `avatars/${playerUuid}/${Date.now()}-${file.name}`;
  
  await this.filesBinding.put(r2Key, file, {
    httpMetadata: {
      contentType: file.type,
      cacheControl: 'public, max-age=31536000' // 1 year cache
    }
  });

  return r2Key;
}

// Generate R2 URL
generateR2Url(r2Key: string): string {
  return `${this.baseUrl}/api/files/r2/${r2Key}`;
}

Multiple Sizes

// Generate multiple avatar sizes
async generateAvatarSizes(playerUuid: string, originalKey: string) {
  const sizes = [
    { width: 64, height: 64, suffix: '64x64' },
    { width: 128, height: 128, suffix: '128x128' },
    { width: 256, height: 256, suffix: '256x256' }
  ];

  for (const size of sizes) {
    const resizedKey = originalKey.replace(/\.(\w+)$/, `-${size.suffix}.$1`);
    // Resize and store image
    await this.resizeAndStore(originalKey, resizedKey, size);
  }
}

Fallback Logic

Avatar Resolution Priority

async resolveAvatar(playerUuid: string): Promise<ResolvedAvatar> {
  // 1. Check custom avatar first
  if (avatarData.custom_avatar_url) {
    const isValid = await this.validateAvatarUrl(avatarData.custom_avatar_url);
    if (isValid) {
      return {
        url: avatarData.custom_avatar_url,
        source: 'custom',
        fallbackInitials,
        isValid: true
      };
    }
  }

  // 2. Fallback to OAuth avatar
  if (avatarData.oauth_avatar_url) {
    const isValid = await this.validateAvatarUrl(avatarData.oauth_avatar_url);
    if (isValid) {
      return {
        url: avatarData.oauth_avatar_url,
        source: 'oauth',
        fallbackInitials,
        isValid: true
      };
    }
  }

  // 3. Default fallback
  return {
    url: this.generateDefaultAvatarUrl(fallbackInitials),
    source: 'default',
    fallbackInitials,
    isValid: true
  };
}

Initials Generation

private generateInitials(name: string): string {
  if (!name) return '?';
  
  const words = name.trim().split(/\s+/);
  if (words.length === 1) {
    return words[0].charAt(0).toUpperCase();
  }
  
  return words
    .slice(0, 2)
    .map(word => word.charAt(0).toUpperCase())
    .join('');
}

Component Integration

React Avatar Component

import { useState, useEffect } from 'react';
import { getPlayerAvatarUrl } from '@/lib/utils/avatar-resolver';

interface AvatarProps {
  playerUuid: string;
  size?: 'small' | 'medium' | 'large';
  className?: string;
}

function Avatar({ playerUuid, size = 'medium', className }: AvatarProps) {
  const [avatarUrl, setAvatarUrl] = useState<string>('');
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(false);

  useEffect(() => {
    async function loadAvatar() {
      try {
        setLoading(true);
        setError(false);
        
        const url = await getPlayerAvatarUrl(db, playerUuid, filesBinding, baseUrl);
        setAvatarUrl(url);
      } catch (err) {
        console.error('Failed to load avatar:', err);
        setError(true);
        setAvatarUrl('/default-avatar.png');
      } finally {
        setLoading(false);
      }
    }

    loadAvatar();
  }, [playerUuid]);

  const sizeClasses = {
    small: 'w-8 h-8',
    medium: 'w-12 h-12',
    large: 'w-16 h-16'
  };

  if (loading) {
    return (
      <div className={`${sizeClasses[size]} bg-gray-200 rounded-full animate-pulse ${className}`} />
    );
  }

  return (
    <img
      src={avatarUrl}
      alt="Player avatar"
      className={`${sizeClasses[size]} rounded-full object-cover ${className}`}
      onError={() => setError(true)}
    />
  );
}

Avatar Upload Component

import { useState } from 'react';

function AvatarUpload({ playerUuid, onAvatarUpdate }: { 
  playerUuid: string; 
  onAvatarUpdate: (avatarInfo: any) => void;
}) {
  const [uploading, setUploading] = useState(false);

  const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (!file) return;

    // Validate file
    if (!file.type.startsWith('image/')) {
      alert('Please select an image file');
      return;
    }

    if (file.size > 5 * 1024 * 1024) { // 5MB limit
      alert('File size must be less than 5MB');
      return;
    }

    try {
      setUploading(true);
      const avatarInfo = await handleAvatarUpload(playerUuid, file);
      onAvatarUpdate(avatarInfo);
    } catch (error) {
      alert('Failed to upload avatar');
    } finally {
      setUploading(false);
    }
  };

  return (
    <div className="avatar-upload">
      <input
        type="file"
        accept="image/*"
        onChange={handleFileSelect}
        disabled={uploading}
        className="hidden"
        id="avatar-upload"
      />
      <label
        htmlFor="avatar-upload"
        className={`cursor-pointer ${uploading ? 'opacity-50' : ''}`}
      >
        {uploading ? 'Uploading...' : 'Change Avatar'}
      </label>
    </div>
  );
}

Performance Optimization

Caching Strategy

// Cache avatar URLs
const avatarCache = new Map<string, { url: string; timestamp: number }>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes

async function getCachedAvatar(playerUuid: string): Promise<string | null> {
  const cached = avatarCache.get(playerUuid);
  
  if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
    return cached.url;
  }
  
  return null;
}

async function setCachedAvatar(playerUuid: string, url: string): Promise<void> {
  avatarCache.set(playerUuid, { url, timestamp: Date.now() });
}

Lazy Loading

// Lazy load avatars
function LazyAvatar({ playerUuid, size }: { playerUuid: string; size: string }) {
  const [isVisible, setIsVisible] = useState(false);
  const [avatarUrl, setAvatarUrl] = useState<string>('');

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.disconnect();
        }
      },
      { threshold: 0.1 }
    );

    const element = document.getElementById(`avatar-${playerUuid}`);
    if (element) {
      observer.observe(element);
    }

    return () => observer.disconnect();
  }, [playerUuid]);

  useEffect(() => {
    if (isVisible) {
      loadAvatar();
    }
  }, [isVisible]);

  return (
    <div id={`avatar-${playerUuid}`} className="avatar-container">
      {isVisible ? (
        <img src={avatarUrl} alt="Avatar" className="avatar" />
      ) : (
        <div className="avatar-placeholder" />
      )}
    </div>
  );
}

Testing

Avatar Resolution Testing

describe('Avatar Resolution', () => {
  let avatarResolver: AvatarResolver;

  beforeEach(() => {
    avatarResolver = new AvatarResolver(mockDb, mockFilesBinding, 'https://example.com');
  });

  it('should resolve custom avatar when available', async () => {
    const mockAvatarData = {
      player_uuid: 'test-uuid',
      username: 'testuser',
      custom_avatar_url: 'https://example.com/custom.jpg',
      oauth_avatar_url: 'https://example.com/oauth.jpg',
      avatar_source: 'custom'
    };

    jest.spyOn(mockDb, 'prepare').mockReturnValue({
      bind: jest.fn().mockReturnValue({
        first: jest.fn().mockResolvedValue(mockAvatarData)
      })
    });

    const avatar = await avatarResolver.resolveAvatar('test-uuid');
    
    expect(avatar.source).toBe('custom');
    expect(avatar.url).toBe('https://example.com/custom.jpg');
  });

  it('should fallback to OAuth avatar when custom is invalid', async () => {
    const mockAvatarData = {
      player_uuid: 'test-uuid',
      username: 'testuser',
      custom_avatar_url: 'https://invalid-url.com/avatar.jpg',
      oauth_avatar_url: 'https://example.com/oauth.jpg',
      avatar_source: 'oauth'
    };

    jest.spyOn(mockDb, 'prepare').mockReturnValue({
      bind: jest.fn().mockReturnValue({
        first: jest.fn().mockResolvedValue(mockAvatarData)
      })
    });

    // Mock URL validation
    jest.spyOn(avatarResolver, 'validateAvatarUrl')
      .mockResolvedValueOnce(false)  // Custom avatar invalid
      .mockResolvedValueOnce(true);  // OAuth avatar valid

    const avatar = await avatarResolver.resolveAvatar('test-uuid');
    
    expect(avatar.source).toBe('oauth');
    expect(avatar.url).toBe('https://example.com/oauth.jpg');
  });
});

This avatar resolution system provides robust, multi-source avatar handling with intelligent fallback logic and performance optimization.

PadawanForge v1.4.1