150 lines
4.5 KiB
TypeScript
150 lines
4.5 KiB
TypeScript
import { useRef, useState } from 'react'
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { queryOptions } from '@tanstack/react-query'
|
|
import { api } from '@/lib/api-client'
|
|
import { useAuthStore } from '@/stores/auth.store'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Camera, User } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
|
|
interface FileRecord {
|
|
id: string
|
|
path: string
|
|
url: string
|
|
filename: string
|
|
}
|
|
|
|
function entityFilesOptions(entityType: string, entityId: string) {
|
|
return queryOptions({
|
|
queryKey: ['files', entityType, entityId],
|
|
queryFn: () => api.get<{ data: FileRecord[] }>('/v1/files', { entityType, entityId }),
|
|
enabled: !!entityId,
|
|
})
|
|
}
|
|
|
|
interface AvatarUploadProps {
|
|
entityType: 'user' | 'member' | 'company'
|
|
entityId: string
|
|
size?: 'sm' | 'md' | 'lg'
|
|
category?: string
|
|
placeholderIcon?: React.ComponentType<{ className?: string }>
|
|
}
|
|
|
|
const sizeClasses = {
|
|
sm: 'h-8 w-8',
|
|
md: 'h-16 w-16',
|
|
lg: 'h-24 w-24',
|
|
}
|
|
|
|
const iconSizes = {
|
|
sm: 'h-4 w-4',
|
|
md: 'h-8 w-8',
|
|
lg: 'h-12 w-12',
|
|
}
|
|
|
|
export function AvatarUpload({ entityType, entityId, size = 'lg', category = 'profile', placeholderIcon: PlaceholderIcon }: AvatarUploadProps) {
|
|
const queryClient = useQueryClient()
|
|
const token = useAuthStore((s) => s.token)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const [uploading, setUploading] = useState(false)
|
|
const IconComponent = PlaceholderIcon ?? User
|
|
|
|
const { data: filesData } = useQuery(entityFilesOptions(entityType, entityId))
|
|
|
|
// Find image by category
|
|
const profileFile = filesData?.data?.find((f) => f.path.includes(`/${category}-`))
|
|
const imageUrl = profileFile ? `/v1/files/serve/${profileFile.path}` : null
|
|
|
|
async function handleUpload(file: File) {
|
|
setUploading(true)
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
formData.append('entityType', entityType)
|
|
formData.append('entityId', entityId)
|
|
formData.append('category', category)
|
|
|
|
// Delete existing profile image first
|
|
if (profileFile) {
|
|
await api.del(`/v1/files/${profileFile.id}`)
|
|
}
|
|
|
|
const res = await fetch('/v1/files', {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
body: formData,
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json()
|
|
throw new Error(err.error?.message ?? 'Upload failed')
|
|
}
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['files', entityType, entityId] })
|
|
toast.success('Profile picture updated')
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Upload failed')
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0]
|
|
if (file) handleUpload(file)
|
|
// Reset input so same file can be re-selected
|
|
e.target.value = ''
|
|
}
|
|
|
|
return (
|
|
<div className="relative inline-block">
|
|
<div
|
|
className={`${sizeClasses[size]} rounded-full bg-muted flex items-center justify-center overflow-hidden border-2 border-border`}
|
|
>
|
|
{imageUrl ? (
|
|
<img
|
|
src={imageUrl}
|
|
alt="Profile"
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
<IconComponent className={`${iconSizes[size]} text-muted-foreground`} />
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
className="absolute -bottom-1 -right-1 h-7 w-7 rounded-full p-0"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploading}
|
|
>
|
|
<Camera className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp"
|
|
className="hidden"
|
|
onChange={handleFileSelect}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Display-only avatar (no upload button) */
|
|
export function Avatar({ entityType, entityId, size = 'sm' }: AvatarUploadProps) {
|
|
const { data: filesData } = useQuery(entityFilesOptions(entityType, entityId))
|
|
const profileFile = filesData?.data?.find((f) => f.path.includes('/profile-'))
|
|
const imageUrl = profileFile ? `/v1/files/serve/${profileFile.path}` : null
|
|
|
|
return (
|
|
<div className={`${sizeClasses[size]} rounded-full bg-muted flex items-center justify-center overflow-hidden`}>
|
|
{imageUrl ? (
|
|
<img src={imageUrl} alt="Profile" className="h-full w-full object-cover" />
|
|
) : (
|
|
<User className={`${iconSizes[size]} text-muted-foreground`} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|