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
- Custom Avatars: User-uploaded images stored in R2
- OAuth Avatars: Profile pictures from authentication providers
- 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.