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:
2
TODO.md
2
TODO.md
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'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'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'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'avatar custom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -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' })}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
248
src/components/ui-showcase/sections/AvatarSection.tsx
Normal file
248
src/components/ui-showcase/sections/AvatarSection.tsx
Normal 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'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'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'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'avatar</div>
|
||||
<div><span className="text-[var(--accent)]">email?</span> - Email pour Gravatar</div>
|
||||
<div><span className="text-[var(--accent)]">name?</span> - Nom d'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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { AvatarSection } from './AvatarSection';
|
||||
export { ButtonsSection } from './ButtonsSection';
|
||||
export { BadgesSection } from './BadgesSection';
|
||||
export { CardsSection } from './CardsSection';
|
||||
|
||||
49
src/components/ui/Avatar.tsx
Normal file
49
src/components/ui/Avatar.tsx
Normal 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
125
src/lib/avatars.ts
Normal 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
68
src/lib/gravatar.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user