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
|
### 🔧 Fonctionnalités et Intégrations
|
||||||
- [ ] **Synchro Jira et TFS shortcuts** - Ajouter des raccourcis et bouton dans Kanban
|
- [ ] **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)
|
- [ ] **Log d'activité** - Implémenter un système de log d'activité (feature potentielle)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ function TaskCard({ task }) {
|
|||||||
</StyledCard>
|
</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
|
## 🔄 Migration
|
||||||
|
|
||||||
### Étape 1: Identifier les patterns
|
### Étape 1: Identifier les patterns
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { getServerSession } from 'next-auth/next'
|
|||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { usersService } from '@/services/users'
|
import { usersService } from '@/services/users'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { getGravatarUrl } from '@/lib/gravatar'
|
||||||
|
|
||||||
export async function updateProfile(formData: {
|
export async function updateProfile(formData: {
|
||||||
name?: string
|
name?: string
|
||||||
firstName?: string
|
firstName?: string
|
||||||
lastName?: string
|
lastName?: string
|
||||||
avatar?: string
|
avatar?: string
|
||||||
|
useGravatar?: boolean
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions)
|
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' }
|
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
|
// Mettre à jour l'utilisateur
|
||||||
const updatedUser = await usersService.updateUser(session.user.id, {
|
const updatedUser = await usersService.updateUser(session.user.id, {
|
||||||
name: formData.name || null,
|
name: formData.name || null,
|
||||||
firstName: formData.firstName || null,
|
firstName: formData.firstName || null,
|
||||||
lastName: formData.lastName || null,
|
lastName: formData.lastName || null,
|
||||||
avatar: formData.avatar || null,
|
avatar: finalAvatarUrl,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Revalider la page de profil
|
// 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' }
|
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 { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Header } from '@/components/ui/Header'
|
import { Header } from '@/components/ui/Header'
|
||||||
import { updateProfile, getProfile } from '@/actions/profile'
|
import { updateProfile, getProfile, applyGravatar } from '@/actions/profile'
|
||||||
import { Check, User, Mail, Calendar, Shield, Save, X, Loader2 } from 'lucide-react'
|
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 {
|
interface UserProfile {
|
||||||
id: string
|
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) => {
|
const handleChange = (field: string, value: string) => {
|
||||||
setFormData(prev => ({ ...prev, [field]: value }))
|
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="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">
|
<div className="flex flex-col md:flex-row items-center md:items-start gap-6">
|
||||||
{/* Avatar section */}
|
{/* Avatar section */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0 relative">
|
||||||
{profile.avatar ? (
|
<Avatar
|
||||||
<div className="relative">
|
url={profile.avatar || undefined}
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
email={profile.email}
|
||||||
<img
|
name={profile.name || undefined}
|
||||||
src={profile.avatar}
|
size={96}
|
||||||
alt="Avatar"
|
className="w-24 h-24 border-4 border-[var(--primary)]/30 shadow-lg"
|
||||||
className="w-24 h-24 rounded-full object-cover 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">
|
<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" />
|
{isGravatarUrl(profile.avatar) ? (
|
||||||
</div>
|
/* eslint-disable-next-line jsx-a11y/alt-text */
|
||||||
</div>
|
<ExternalLink className="w-4 h-4 text-white" aria-label="Avatar Gravatar" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-24 h-24 rounded-full bg-[var(--primary)]/20 border-4 border-[var(--primary)]/30 flex items-center justify-center">
|
/* eslint-disable-next-line jsx-a11y/alt-text */
|
||||||
<User className="w-12 h-12 text-[var(--primary)]" />
|
<Image className="w-4 h-4 text-white" aria-label="Avatar personnalisé" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -279,28 +317,86 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="avatar" className="block text-sm font-medium text-[var(--foreground)] mb-2">
|
<label htmlFor="avatar" className="block text-sm font-medium text-[var(--foreground)] mb-2">
|
||||||
URL de l'avatar (optionnel)
|
Avatar
|
||||||
</label>
|
</label>
|
||||||
<Input
|
|
||||||
id="avatar"
|
{/* Option Gravatar */}
|
||||||
value={formData.avatar}
|
<div className="space-y-3">
|
||||||
onChange={(e) => handleChange('avatar', e.target.value)}
|
<div className="flex items-center gap-3 p-3 bg-[var(--input)] rounded-lg border border-[var(--border)]">
|
||||||
placeholder="https://example.com/avatar.jpg"
|
<div className="flex-shrink-0">
|
||||||
/>
|
{profile.email && (
|
||||||
{formData.avatar && (
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<img
|
||||||
<span className="text-xs text-[var(--muted-foreground)]">Aperçu:</span>
|
src={getGravatarUrl(profile.email, { size: 40 })}
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
alt="Aperçu Gravatar"
|
||||||
<img
|
className="w-10 h-10 rounded-full object-cover"
|
||||||
src={formData.avatar}
|
/>
|
||||||
alt="Aperçu avatar"
|
)}
|
||||||
className="w-8 h-8 rounded-full object-cover border border-[var(--border)]"
|
</div>
|
||||||
onError={(e) => {
|
<div className="flex-1">
|
||||||
e.currentTarget.style.display = 'none'
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { useSession, signOut } from 'next-auth/react'
|
import { useSession, signOut } from 'next-auth/react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Button } from '@/components/ui/Button'
|
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() {
|
export function AuthButton() {
|
||||||
const { data: session, status } = useSession()
|
const { data: session, status } = useSession()
|
||||||
@@ -37,16 +38,13 @@ export function AuthButton() {
|
|||||||
className="p-1 h-auto"
|
className="p-1 h-auto"
|
||||||
title={`Profil - ${session.user?.email}`}
|
title={`Profil - ${session.user?.email}`}
|
||||||
>
|
>
|
||||||
{session.user?.avatar ? (
|
<Avatar
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
url={session.user?.avatar}
|
||||||
<img
|
email={session.user?.email}
|
||||||
src={session.user.avatar}
|
name={session.user?.name}
|
||||||
alt="Avatar"
|
size={40}
|
||||||
className="w-10 h-10 rounded-full object-cover"
|
className="w-10 h-10"
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<User className="w-6 h-6" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { TableOfContents } from './TableOfContents';
|
import { TableOfContents } from './TableOfContents';
|
||||||
import {
|
import {
|
||||||
|
AvatarSection,
|
||||||
ButtonsSection,
|
ButtonsSection,
|
||||||
BadgesSection,
|
BadgesSection,
|
||||||
CardsSection,
|
CardsSection,
|
||||||
@@ -34,6 +35,7 @@ export function UIShowcaseClient() {
|
|||||||
<ButtonsSection />
|
<ButtonsSection />
|
||||||
<BadgesSection />
|
<BadgesSection />
|
||||||
<CardsSection />
|
<CardsSection />
|
||||||
|
<AvatarSection />
|
||||||
<DropdownsSection />
|
<DropdownsSection />
|
||||||
<FormsSection />
|
<FormsSection />
|
||||||
<NavigationSection />
|
<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 { ButtonsSection } from './ButtonsSection';
|
||||||
export { BadgesSection } from './BadgesSection';
|
export { BadgesSection } from './BadgesSection';
|
||||||
export { CardsSection } from './CardsSection';
|
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