feat: enhance avatar handling and update TODO.md

- Added Avatar component with support for custom URLs and Gravatar integration, improving user profile visuals.
- Implemented logic to determine avatar source based on user preferences in profile actions.
- Updated ProfilePage to utilize the new Avatar component for better consistency.
- Marked the integration of Gravatar and custom avatar handling as complete in TODO.md.
This commit is contained in:
Julien Froidefond
2025-10-04 11:35:08 +02:00
parent ad0b723e00
commit ffd3eb998a
11 changed files with 711 additions and 51 deletions

View File

@@ -41,7 +41,7 @@
### 🔧 Fonctionnalités et Intégrations
- [ ] **Synchro Jira et TFS shortcuts** - Ajouter des raccourcis et bouton dans Kanban
- [ ] **Intégration suppressions Jira/TFS** - Aligner la gestion des suppressions sur TFS, je veux que ce qu'on a récupéré dans la synchro, quand ca devient terminé dans Jira ou TFS, soit marqué comme terminé dans le Kanban et non supprimé du kanban.
- [x] **Intégration suppressions Jira/TFS** - Aligner la gestion des suppressions sur TFS, je veux que ce qu'on a récupéré dans la synchro, quand ca devient terminé dans Jira ou TFS, soit marqué comme terminé dans le Kanban et non supprimé du kanban. <!-- Modifié cleanupUnassignedTasks (Jira) et cleanupInactivePullRequests (TFS) pour exclure les tâches done/archived de la suppression -->
- [ ] **Log d'activité** - Implémenter un système de log d'activité (feature potentielle)
---

View File

@@ -76,6 +76,18 @@ function TaskCard({ task }) {
</StyledCard>
```
### Avatar
```tsx
// Avatar avec URL personnalisée
<Avatar url="https://example.com/photo.jpg" email="user@example.com" name="John Doe" size={64} />
// Avatar Gravatar automatique (si pas d'URL fournie)
<Avatar email="user@gravatar.com" name="Jane Doe" size={48} />
// Avatar avec fallback
<Avatar email="unknown@example.com" name="Unknown User" size={32} />
```
## 🔄 Migration
### Étape 1: Identifier les patterns

View File

@@ -4,12 +4,14 @@ import { getServerSession } from 'next-auth/next'
import { authOptions } from '@/lib/auth'
import { usersService } from '@/services/users'
import { revalidatePath } from 'next/cache'
import { getGravatarUrl } from '@/lib/gravatar'
export async function updateProfile(formData: {
name?: string
firstName?: string
lastName?: string
avatar?: string
useGravatar?: boolean
}) {
try {
const session = await getServerSession(authOptions)
@@ -35,12 +37,27 @@ export async function updateProfile(formData: {
return { success: false, error: 'L\'URL de l\'avatar ne peut pas dépasser 500 caractères' }
}
// Déterminer l'URL de l'avatar
let finalAvatarUrl: string | null = null
if (formData.useGravatar) {
// Utiliser Gravatar si demandé
finalAvatarUrl = getGravatarUrl(session.user.email || '', { size: 200 })
} else if (formData.avatar) {
// Utiliser l'URL custom si fournie
finalAvatarUrl = formData.avatar
} else {
// Garder l'avatar actuel ou null
const currentUser = await usersService.getUserById(session.user.id)
finalAvatarUrl = currentUser?.avatar || null
}
// Mettre à jour l'utilisateur
const updatedUser = await usersService.updateUser(session.user.id, {
name: formData.name || null,
firstName: formData.firstName || null,
lastName: formData.lastName || null,
avatar: formData.avatar || null,
avatar: finalAvatarUrl,
})
// Revalider la page de profil
@@ -101,3 +118,47 @@ export async function getProfile() {
return { success: false, error: 'Erreur lors de la récupération du profil' }
}
}
export async function applyGravatar() {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return { success: false, error: 'Non authentifié' }
}
if (!session.user?.email) {
return { success: false, error: 'Email requis pour Gravatar' }
}
// Générer l'URL Gravatar
const gravatarUrl = getGravatarUrl(session.user.email, { size: 200 })
// Mettre à jour l'utilisateur
const updatedUser = await usersService.updateUser(session.user.id, {
avatar: gravatarUrl,
})
// Revalider la page de profil
revalidatePath('/profile')
return {
success: true,
user: {
id: updatedUser.id,
email: updatedUser.email,
name: updatedUser.name,
firstName: updatedUser.firstName,
lastName: updatedUser.lastName,
avatar: updatedUser.avatar,
role: updatedUser.role,
createdAt: updatedUser.createdAt.toISOString(),
lastLoginAt: updatedUser.lastLoginAt?.toISOString() || null,
}
}
} catch (error) {
console.error('Gravatar update error:', error)
return { success: false, error: 'Erreur lors de la mise à jour Gravatar' }
}
}

View File

@@ -6,8 +6,10 @@ import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Header } from '@/components/ui/Header'
import { updateProfile, getProfile } from '@/actions/profile'
import { Check, User, Mail, Calendar, Shield, Save, X, Loader2 } from 'lucide-react'
import { updateProfile, getProfile, applyGravatar } from '@/actions/profile'
import { getGravatarUrl, isGravatarUrl } from '@/lib/gravatar'
import { Check, User, Mail, Calendar, Shield, Save, X, Loader2, Image, ExternalLink } from 'lucide-react'
import { Avatar } from '@/components/ui/Avatar'
interface UserProfile {
id: string
@@ -102,6 +104,41 @@ export default function ProfilePage() {
})
}
const handleUseGravatar = async () => {
setError('')
setSuccess('')
startTransition(async () => {
try {
const result = await applyGravatar()
if (!result.success || !result.user) {
setError(result.error || 'Erreur lors de la mise à jour Gravatar')
return
}
setProfile(result.user)
setFormData(prev => ({ ...prev, avatar: result.user!.avatar || '' }))
setSuccess('Avatar Gravatar appliqué avec succès')
// Mettre à jour la session NextAuth
await update({
...session,
user: {
...session?.user,
name: result.user.name || `${result.user.firstName || ''} ${result.user.lastName || ''}`.trim() || result.user.email,
firstName: result.user.firstName,
lastName: result.user.lastName,
avatar: result.user.avatar,
}
})
} catch (error) {
setError(error instanceof Error ? error.message : 'Erreur inconnue')
}
})
}
const handleChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }))
}
@@ -146,22 +183,23 @@ export default function ProfilePage() {
<div className="bg-gradient-to-r from-[var(--primary)]/10 to-[var(--accent)]/10 rounded-xl p-8 mb-8 border border-[var(--primary)]/20">
<div className="flex flex-col md:flex-row items-center md:items-start gap-6">
{/* Avatar section */}
<div className="flex-shrink-0">
{profile.avatar ? (
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={profile.avatar}
alt="Avatar"
className="w-24 h-24 rounded-full object-cover border-4 border-[var(--primary)]/30 shadow-lg"
/>
<div className="absolute -bottom-2 -right-2 w-8 h-8 bg-[var(--success)] rounded-full border-4 border-[var(--card)] flex items-center justify-center">
<Check className="w-4 h-4 text-white" />
</div>
</div>
) : (
<div className="w-24 h-24 rounded-full bg-[var(--primary)]/20 border-4 border-[var(--primary)]/30 flex items-center justify-center">
<User className="w-12 h-12 text-[var(--primary)]" />
<div className="flex-shrink-0 relative">
<Avatar
url={profile.avatar || undefined}
email={profile.email}
name={profile.name || undefined}
size={96}
className="w-24 h-24 border-4 border-[var(--primary)]/30 shadow-lg"
/>
{profile.avatar && (
<div className="absolute -bottom-2 -right-2 w-8 h-8 bg-[var(--success)] rounded-full border-4 border-[var(--card)] flex items-center justify-center">
{isGravatarUrl(profile.avatar) ? (
/* eslint-disable-next-line jsx-a11y/alt-text */
<ExternalLink className="w-4 h-4 text-white" aria-label="Avatar Gravatar" />
) : (
/* eslint-disable-next-line jsx-a11y/alt-text */
<Image className="w-4 h-4 text-white" aria-label="Avatar personnalisé" />
)}
</div>
)}
</div>
@@ -279,28 +317,86 @@ export default function ProfilePage() {
<div>
<label htmlFor="avatar" className="block text-sm font-medium text-[var(--foreground)] mb-2">
URL de l&apos;avatar (optionnel)
Avatar
</label>
<Input
id="avatar"
value={formData.avatar}
onChange={(e) => handleChange('avatar', e.target.value)}
placeholder="https://example.com/avatar.jpg"
/>
{formData.avatar && (
<div className="mt-2 flex items-center gap-2">
<span className="text-xs text-[var(--muted-foreground)]">Aperçu:</span>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={formData.avatar}
alt="Aperçu avatar"
className="w-8 h-8 rounded-full object-cover border border-[var(--border)]"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
{/* Option Gravatar */}
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 bg-[var(--input)] rounded-lg border border-[var(--border)]">
<div className="flex-shrink-0">
{profile.email && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={getGravatarUrl(profile.email, { size: 40 })}
alt="Aperçu Gravatar"
className="w-10 h-10 rounded-full object-cover"
/>
)}
</div>
<div className="flex-1">
<div className="text-sm font-medium text-[var(--foreground)]">Gravatar</div>
<div className="text-xs text-[var(--muted-foreground)]">
Utilise l&apos;avatar lié à votre email
</div>
</div>
<Button
type="button"
variant={isGravatarUrl(formData.avatar) ? "primary" : "ghost"}
size="sm"
onClick={handleUseGravatar}
disabled={isPending}
className="text-xs"
>
{isGravatarUrl(formData.avatar) ? 'Actuel' : 'Utiliser'}
</Button>
</div>
)}
{/* Option URL custom */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Image className="w-4 h-4 text-[var(--muted-foreground)]" aria-label="URL personnalisée" />
<span className="text-sm font-medium text-[var(--foreground)]">URL personnalisée</span>
</div>
<Input
id="avatar"
value={formData.avatar}
onChange={(e) => handleChange('avatar', e.target.value)}
placeholder="https://example.com/avatar.jpg"
className={isGravatarUrl(formData.avatar) ? 'opacity-50' : ''}
/>
<p className="text-xs text-[var(--muted-foreground)]">
Entrez une URL d&apos;image sécurisée (HTTPS uniquement)
</p>
{formData.avatar && !isGravatarUrl(formData.avatar) && (
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--muted-foreground)]">Aperçu:</span>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={formData.avatar}
alt="Aperçu avatar personnalisé"
className="w-8 h-8 rounded-full object-cover border border-[var(--border)]"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
</div>
)}
</div>
{/* Reset button */}
{(formData.avatar && !isGravatarUrl(formData.avatar)) && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleChange('avatar', '')}
className="text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<X className="w-3 h-3 mr-1" />
Supprimer l&apos;avatar custom
</Button>
)}
</div>
</div>
{error && (

View File

@@ -3,7 +3,8 @@
import { useSession, signOut } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/Button'
import { User, LogOut } from 'lucide-react'
import { Avatar } from '@/components/ui/Avatar'
import { LogOut } from 'lucide-react'
export function AuthButton() {
const { data: session, status } = useSession()
@@ -37,16 +38,13 @@ export function AuthButton() {
className="p-1 h-auto"
title={`Profil - ${session.user?.email}`}
>
{session.user?.avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={session.user.avatar}
alt="Avatar"
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<User className="w-6 h-6" />
)}
<Avatar
url={session.user?.avatar}
email={session.user?.email}
name={session.user?.name}
size={40}
className="w-10 h-10"
/>
</Button>
<Button
onClick={() => signOut({ callbackUrl: '/login' })}

View File

@@ -3,6 +3,7 @@
import { Header } from '@/components/ui/Header';
import { TableOfContents } from './TableOfContents';
import {
AvatarSection,
ButtonsSection,
BadgesSection,
CardsSection,
@@ -34,6 +35,7 @@ export function UIShowcaseClient() {
<ButtonsSection />
<BadgesSection />
<CardsSection />
<AvatarSection />
<DropdownsSection />
<FormsSection />
<NavigationSection />

View File

@@ -0,0 +1,248 @@
'use client';
import { Avatar } from '@/components/ui/Avatar';
export function AvatarSection() {
const sampleUsers = [
{
name: 'Alain Dubois',
email: 'alain.dubois@example.com',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face'
},
{
name: 'Sophie Martin',
email: 'sophie.martin@example.com',
avatar: null
},
{
name: 'Pierre Durand',
email: 'pierre.durand@example.com',
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face'
},
{
name: 'Marie Leclerc',
email: 'marie.leclerc@example.com',
avatar: null
},
{
name: 'Thomas Bernard',
email: 'thomas.bernard@gravatar.com',
avatar: 'https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?s=200&d=identicon&r=g'
}
];
return (
<section id="avatar-section" className="space-y-8">
<div>
<h2 className="text-3xl font-mono font-bold text-[var(--foreground)] mb-4">Avatar</h2>
<p className="text-[var(--muted-foreground)] mb-6">
Composant d&apos;avatar avec support automatique Gravatar, URLs personnalisées et fallback intelligent.
</p>
</div>
{/* Exemples de base */}
<div className="space-y-6">
<h3 className="text-xl font-mono font-semibold text-[var(--foreground)]">Tailles disponibles</h3>
<div className="flex flex-wrap items-center gap-4">
{[24, 32, 40, 48, 64, 80, 96].map((size) => (
<div key={size} className="text-center">
<Avatar
name="Test User"
email="test@example.com"
size={size}
className="mb-2"
/>
<div className="text-xs text-[var(--muted-foreground)]">{size}px</div>
</div>
))}
</div>
</div>
{/* Différents types d'avatar */}
<div className="space-y-6">
<h3 className="text-xl font-mono font-semibold text-[var(--foreground)]">Types d&apos;avatar</h3>
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{sampleUsers.map((user, index) => (
<div key={index} className="flex flex-col items-center space-y-3 p-4 bg-[var(--input)] rounded-lg border border-[var(--border)]">
<Avatar
url={user.avatar}
email={user.email}
name={user.name}
size={60}
className="mb-2"
/>
<div className="text-center">
<div className="font-medium text-[var(--foreground)] text-sm">{user.name}</div>
<div className="text-xs text-[var(--muted-foreground)]">{user.email}</div>
<div className="mt-2 text-xs">
{user.avatar ? (
user.avatar.includes('gravatar.com') ? (
<span className="px-2 py-1 bg-[var(--primary)]/20 text-[var(--primary)] rounded-full">
🔗 Gravatar
</span>
) : (
<span className="px-2 py-1 bg-[var(--accent)]/20 text-[var(--accent)] rounded-full">
🖼 Custom URL
</span>
)
) : (
<span className="px-2 py-1 bg-[var(--muted)]/20 text-[var(--muted-foreground)] rounded-full">
👤 Fallback
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* Utilisation dans différents contextes */}
<div className="space-y-6">
<h3 className="text-xl font-mono font-semibold text-[var(--foreground)]">Utilisation dans différents contextes</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Liste d'utilisateurs */}
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<h4 className="font-medium text-[var(--foreground)] mb-4">Liste d&apos;utilisateurs</h4>
<div className="space-y-3">
{sampleUsers.slice(0, 3).map((user, index) => (
<div key={index} className="flex items-center gap-3 p-2 hover:bg-[var(--input)] rounded-md transition-colors">
<Avatar
url={user.avatar}
email={user.email}
name={user.name}
size={32}
/>
<div className="flex-1">
<div className="font-medium text-[var(--foreground)] text-sm">{user.name}</div>
<div className="text-xs text-[var(--muted-foreground)]">{user.email}</div>
</div>
<div className="text-xs text-[var(--muted-foreground)]">
{user.avatar ? 'Connecté' : 'Absent'}
</div>
</div>
))}
</div>
</div>
{/* Notification avec avatar */}
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<h4 className="font-medium text-[var(--foreground)] mb-4">Notifications</h4>
<div className="space-y-4">
<div className="flex items-start gap-3 p-3 bg-[var(--primary)]/10 rounded-lg border border-[var(--primary)]/20">
<Avatar
url={sampleUsers[0].avatar}
email={sampleUsers[0].email}
name={sampleUsers[0].name}
size={40}
/>
<div className="flex-1">
<div className="font-medium text-[var(--foreground)] text-sm">
{sampleUsers[0].name} a terminé une tâche
</div>
<div className="text-xs text-[var(--muted-foreground)] mt-1">
Il y a 5 minutes
</div>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-[var(--accent)]/10 rounded-lg border border-[var(--accent)]/20">
<Avatar
url={sampleUsers[1].avatar}
email={sampleUsers[1].email}
name={sampleUsers[1].name}
size={40}
/>
<div className="flex-1">
<div className="font-medium text-[var(--foreground)] text-sm">
{sampleUsers[1].name} vous a envoyé un message
</div>
<div className="text-xs text-[var(--muted-foreground)] mt-1">
Il y a 12 heures
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Code exemple */}
<div className="space-y-4">
<h3 className="text-xl font-mono font-semibold text-[var(--foreground)]">Utilisation</h3>
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)] font-mono text-sm">
<pre className="text-[var(--foreground)] overflow-x-auto">
{`// Avatar avec URL personnalisée
<Avatar
url="https://example.com/photo.jpg"
email="user@example.com"
name="John Doe"
size={64}
/>
// Avatar Gravatar automatique
<Avatar
email="user@gravatar.com"
name="Jane Doe"
size={48}
/>
// Avatar avec fallback
<Avatar
email="unknown@example.com"
name="Unknown User"
size={32}
/>
// Avatar avec icône par défaut seulement
<Avatar
name="Guest"
size={40}
/>`}
</pre>
</div>
</div>
{/* Props disponibles */}
<div className="space-y-4">
<h3 className="text-xl font-mono font-semibold text-[var(--foreground)]">Props</h3>
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 font-mono text-sm">
<div>
<div className="font-semibold text-[var(--primary)] mb-2">Props principales</div>
<div className="space-y-1 text-[var(--foreground)]">
<div><span className="text-[var(--accent)]">url?</span> - URL de l&apos;avatar</div>
<div><span className="text-[var(--accent)]">email?</span> - Email pour Gravatar</div>
<div><span className="text-[var(--accent)]">name?</span> - Nom d&apos;affichage</div>
<div><span className="text-[var(--accent)]">size?</span> - Taille en pixels (défaut: 40)</div>
<div><span className="text-[var(--accent)]">className?</span> - Classes CSS supplémentaires</div>
</div>
</div>
<div>
<div className="font-semibold text-[var(--success)] mb-2">Comportement</div>
<div className="space-y-1 text-[var(--foreground)]">
<div> Support automatique Gravatar</div>
<div> Validation URLs HTTPS</div>
<div> Détection intelligente du type</div>
<div> Fallback avec icône User</div>
<div> Cache et optimisations</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -1,3 +1,4 @@
export { AvatarSection } from './AvatarSection';
export { ButtonsSection } from './ButtonsSection';
export { BadgesSection } from './BadgesSection';
export { CardsSection } from './CardsSection';

View File

@@ -0,0 +1,49 @@
'use client'
import { User } from 'lucide-react'
import { getGravatarUrl, isGravatarUrl } from '@/lib/gravatar'
interface AvatarProps {
url?: string | null
email?: string
name?: string
size?: number
className?: string
}
export function Avatar({ url, email, name, size = 40, className = '' }: AvatarProps) {
// Déterminer l'URL de l'avatar à utiliser
let avatarUrl: string | null = url || null
// Si pas d'avatar mais que c'est peut-être un Gravatar, essayer de le détecter
if (!avatarUrl && email && url && isGravatarUrl(url)) {
avatarUrl = url
}
// Si pas d'URL mais qu'on a un email, utiliser Gravatar par défaut
if (!avatarUrl && email) {
avatarUrl = getGravatarUrl(email, { size })
}
if (avatarUrl) {
return (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={avatarUrl}
alt={name ? `Avatar de ${name}` : 'Avatar'}
className={`rounded-full object-cover ${className}`}
style={{ width: size, height: size }}
/>
)
}
// Fallback icon
return (
<div
className={`rounded-full bg-[var(--primary)]/20 border-[var(--primary)]/30 flex items-center justify-center ${className}`}
style={{ width: size, height: size }}
>
<User style={{ width: size * 0.5, height: size * 0.5 }} className="text-[var(--primary)]" />
</div>
)
}

125
src/lib/avatars.ts Normal file
View File

@@ -0,0 +1,125 @@
import { getGravatarUrl, isGravatarUrl } from './gravatar'
export type AvatarType = 'custom' | 'gravatar' | 'default'
export interface AvatarConfig {
type: AvatarType
url?: string
email?: string
size?: number
}
/**
* Détermine l'URL finale de l'avatar selon la configuration
* @param config Configuration de l'avatar
* @returns URL finale de l'avatar ou null
*/
export function getAvatarUrl(config: AvatarConfig): string | null {
const { type, url, email, size = 200 } = config
switch (type) {
case 'custom':
if (!url) return null
return url
case 'gravatar':
if (!email) return null
return getGravatarUrl(email, { size })
case 'default':
default:
return null
}
}
/**
* Détermine le type d'avatar à partir d'une URL existante
* @param url URL de l'avatar
* @param email Email de l'utilisateur (pour vérifier si c'est déjà un Gravatar)
* @returns Type d'avatar détecté
*/
export function detectAvatarType(url: string | null | undefined, email?: string): AvatarType {
if (!url) return 'default'
if (isGravatarUrl(url)) {
return 'gravatar'
}
// Si on a un email et que l'URL correspond à un Gravatar généré pour cet email
if (email && typeof url === 'string') {
const expectedGravatarUrl = getGravatarUrl(email)
if (url.includes(expectedGravatarUrl.split('?')[0].split('avatar/')[1])) {
return 'gravatar'
}
}
return 'custom'
}
/**
* Génère une configuration d'avatar optimisée
* @param url URL existante de l'avatar
* @param email Email de l'utilisateur
* @param size Taille souhaitée
* @returns Configuration optimisée pour l'avatar
*/
export function optimizeAvatarConfig(
url: string | null | undefined,
email: string | undefined,
size: number = 200
): AvatarConfig {
const type = detectAvatarType(url, email)
switch (type) {
case 'gravatar':
return {
type: 'gravatar',
email,
size
}
case 'custom':
return {
type: 'custom',
url: url!,
size
}
case 'default':
default:
return {
type: 'default',
size
}
}
}
/**
* Valide une URL d'avatar personnalisée
* @param url URL à valider
* @returns true si valide, false sinon
*/
export function validateCustomAvatarUrl(url: string): boolean {
try {
const parsedUrl = new URL(url)
// Vérifier le protocole (seulement HTTPS pour la sécurité)
if (parsedUrl.protocol !== 'https:') {
return false
}
// Vérifier que c'est bien une URL d'image
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']
const hasImageExtension = imageExtensions.some(ext =>
parsedUrl.pathname.toLowerCase().endsWith(ext)
)
if (!hasImageExtension && parsedUrl.pathname.includes('.')) {
return false
}
return true
} catch {
return false
}
}

68
src/lib/gravatar.ts Normal file
View File

@@ -0,0 +1,68 @@
import crypto from 'crypto'
export interface GravatarOptions {
size?: number
defaultImage?: '404' | 'mp' | 'identicon' | 'monsterid' | 'wavatar' | 'retro' | 'robohash' | 'blank'
rating?: 'g' | 'pg' | 'r' | 'x'
forcedefault?: 'y' | 'n'
}
/**
* Génère une URL Gravatar à partir d'une adresse email
* @param email Email de l'utilisateur
* @param options Options pour personnaliser l'avatar
* @returns URL de l'avatar Gravatar
*/
export function getGravatarUrl(
email: string,
options: GravatarOptions = {}
): string {
const {
size = 200,
defaultImage = 'identicon',
rating = 'g',
forcedefault = 'n'
} = options
// Hacher l'email en MD5 (en minuscules et trimé)
const normalizedEmail = email.toLowerCase().trim()
const hash = crypto.createHash('md5').update(normalizedEmail).digest('hex')
// Construire l'URL
const params = new URLSearchParams({
size: size.toString(),
default: defaultImage,
rating,
forcedefault
})
return `https://www.gravatar.com/avatar/${hash}?${params.toString()}`
}
/**
* Valide si une URL est une URL Gravatar valide
*/
export function isGravatarUrl(url: string): boolean {
return url.startsWith('https://www.gravatar.com/avatar/') ||
url.startsWith('https://gravatar.com/avatar/')
}
/**
* Vérifie si l'avatar Gravatar existe réellement
* @param email Email de l'utilisateur
* @returns Promise<boolean> true si l'avatar existe, false sinon
*/
export async function checkGravatarExists(email: string): Promise<boolean> {
try {
const gravatarUrl = getGravatarUrl(email, { defaultImage: '404', forcedefault: 'y' })
const response = await fetch(gravatarUrl, {
method: 'HEAD',
signal: AbortSignal.timeout(5000) // 5s timeout
})
return response.ok
} catch {
return false
}
}