Files
eranos/src/components/ProfileCard.tsx
T
2026-06-14 10:29:35 -07:00

440 lines
18 KiB
TypeScript

import React, { useState } from 'react';
import type { NostrMetadata } from '@nostrify/nostrify';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { CheckCircle2, Pencil, Plus, Trash2, ChevronDown, ImagePlus, X as XIcon, Link as LinkIcon } from 'lucide-react';
import { genUserName } from '@/lib/genUserName';
import { BioContent } from '@/components/BioContent';
import { cn } from '@/lib/utils';
import { getNip05Domain, formatNip05Display } from '@/lib/nip05';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { useProfileBadges } from '@/hooks/useProfileBadges';
import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions';
import { BadgeShowcaseGrid } from '@/components/BadgeShowcaseGrid';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
/** Shared classes for all editable fields — static muted bg when idle, border on hover/focus */
const editableBase = [
'rounded-lg px-2',
'border-2 border-transparent',
'bg-muted/40',
'hover:bg-muted/60 hover:border-border',
'focus:bg-transparent focus:border-primary',
'transition-colors duration-150',
'placeholder:text-muted-foreground/40',
'outline-none',
].join(' ');
function EditableInput({
value,
placeholder,
onChange,
className,
}: {
value: string;
placeholder: string;
onChange: (v: string) => void;
className?: string;
}) {
return (
<input
type="text"
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
className={cn(editableBase, 'w-full min-w-0 py-0.5', className)}
/>
);
}
function EditableTextarea({
value,
placeholder,
onChange,
className,
}: {
value: string;
placeholder: string;
onChange: (v: string) => void;
className?: string;
}) {
return (
<textarea
value={value}
placeholder={placeholder}
onChange={(e) => {
onChange(e.target.value);
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
}}
onFocus={(e) => {
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
}}
rows={1}
className={cn(editableBase, 'w-full min-w-0 py-1 resize-none overflow-hidden text-base md:text-sm text-muted-foreground leading-relaxed', className)}
/>
);
}
interface ProfileField {
label: string;
value: string;
}
interface ProfileCardProps {
className?: string;
pubkey?: string;
metadata: Partial<NostrMetadata>;
onChange?: (patch: Partial<NostrMetadata>) => void;
onPickImage?: (field: 'picture' | 'banner') => void;
/**
* Called when the user chooses "Paste URL" for an image field. The handler
* is expected to read the clipboard, validate the URL, and apply it. When
* provided, the banner gains a dropdown menu (matching the avatar) so the
* paste action is reachable for both images.
*/
onPasteUrl?: (field: 'picture' | 'banner') => void;
/** Called when user removes their avatar picture. */
onRemoveAvatar?: () => void;
/** Show NIP-05 row (default true) */
showNip05?: boolean;
/** Show NIP-58 badge showcase row (default true). */
showBadges?: boolean;
/**
* Which kind-0 field the editable text slot below the name edits.
* - `'about'` (default): the bio textarea.
* - `'website'`: a single-line website input, replacing the bio entirely.
*/
bioField?: 'about' | 'website';
/** When provided, render an editable profile fields section below bio */
extraFields?: ProfileField[];
onExtraFieldsChange?: (fields: ProfileField[]) => void;
}
export function ProfileCard({
className,
pubkey,
metadata,
onChange,
onPickImage,
onPasteUrl,
onRemoveAvatar,
showNip05 = true,
showBadges = true,
bioField = 'about',
extraFields,
onExtraFieldsChange,
}: ProfileCardProps) {
const editable = !!onChange;
const [nip05Focused, setNip05Focused] = useState(false);
const [fieldsOpen, setFieldsOpen] = useState(false);
const { user } = useCurrentUser();
const isOwnProfile = !!pubkey && !!user && pubkey === user.pubkey;
const { refs: badgeRefs, isLoading: badgesLoading } = useProfileBadges(pubkey);
const { badgeMap, isLoading: defsLoading } = useBadgeDefinitions(badgeRefs);
const displayName = metadata.name || metadata.display_name || genUserName(pubkey);
const initial = displayName[0]?.toUpperCase() ?? '?';
const patch = (key: keyof NostrMetadata) => (v: string) => onChange?.({ [key]: v });
// Sanitize banner URL from untrusted metadata before CSS url() interpolation
const bannerUrl = sanitizeUrl(metadata.banner);
const nip05 = metadata.nip05;
const nip05Domain = nip05 ? getNip05Domain(nip05) : undefined;
const addField = () => onExtraFieldsChange?.([...(extraFields ?? []), { label: '', value: '' }]);
const removeField = (i: number) => onExtraFieldsChange?.((extraFields ?? []).filter((_, idx) => idx !== i));
const updateField = (i: number, key: keyof ProfileField, val: string) =>
onExtraFieldsChange?.((extraFields ?? []).map((f, idx) => idx === i ? { ...f, [key]: val } : f));
return (
<div className={cn('bg-card border rounded-xl overflow-hidden', className)}>
{/* Banner */}
{editable && onPasteUrl ? (
// With a paste handler, the banner opens a menu (Change / Paste URL)
// instead of going straight to the file picker.
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="relative block w-full h-36 bg-secondary cursor-pointer group outline-none"
style={
bannerUrl
? { backgroundImage: `url("${bannerUrl}")`, backgroundSize: 'cover', backgroundPosition: 'center' }
: undefined
}
>
{!metadata.banner && <div className="absolute inset-0 bg-gradient-to-br from-accent/10 via-transparent to-primary/5" />}
{!metadata.banner && (
<div className="absolute inset-0 flex items-center justify-center">
<Plus className="size-6 text-muted-foreground" strokeWidth={4} />
</div>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center">
<span className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1.5 text-white text-xs font-medium bg-black/50 rounded-full px-3 py-1.5 backdrop-blur-sm">
<Pencil className="size-3.5" /> {metadata.banner ? 'Change banner' : 'Add banner'}
</span>
</div>
{metadata.banner && (
<div className="absolute bottom-2 right-2 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
<Pencil className="size-3.5 text-muted-foreground" />
</div>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={6}>
<DropdownMenuItem onClick={() => onPickImage?.('banner')}>
<ImagePlus className="size-4 mr-2" />
{metadata.banner ? 'Change banner' : 'Add banner'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onPasteUrl?.('banner')}>
<LinkIcon className="size-4 mr-2" />
Paste URL
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div
className={cn('relative h-36 bg-secondary', editable && 'cursor-pointer group')}
style={
bannerUrl
? { backgroundImage: `url("${bannerUrl}")`, backgroundSize: 'cover', backgroundPosition: 'center' }
: undefined
}
onClick={() => editable && onPickImage?.('banner')}
>
{!metadata.banner && <div className="absolute inset-0 bg-gradient-to-br from-accent/10 via-transparent to-primary/5" />}
{editable && !metadata.banner && (
<div className="absolute inset-0 flex items-center justify-center">
<Plus className="size-6 text-muted-foreground" strokeWidth={4} />
</div>
)}
{editable && (
<>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center">
<span className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1.5 text-white text-xs font-medium bg-black/50 rounded-full px-3 py-1.5 backdrop-blur-sm">
<Pencil className="size-3.5" /> {metadata.banner ? 'Change banner' : 'Add banner'}
</span>
</div>
{metadata.banner && (
<div className="absolute bottom-2 right-2 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
<Pencil className="size-3.5 text-muted-foreground" />
</div>
)}
</>
)}
</div>
)}
{/* Profile info */}
<div className="px-4 pb-4">
{/* Avatar */}
<div className="flex justify-between items-start -mt-12 mb-3">
{editable ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="relative shrink-0 cursor-pointer group outline-none">
<Avatar className="shadow-sm size-24 border-4 border-background">
<AvatarImage src={metadata.picture} alt={displayName} className="object-cover" />
<AvatarFallback className="bg-primary/20 text-primary text-2xl font-bold">
{metadata.picture ? initial : <Plus className="size-8 text-muted-foreground" strokeWidth={4} />}
</AvatarFallback>
</Avatar>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/45 transition-colors flex items-center justify-center rounded-full">
<Pencil className="size-6 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow" />
</div>
{metadata.picture && (
<div className="absolute bottom-0 right-0 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
<Pencil className="size-3.5 text-muted-foreground" />
</div>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={6}>
<DropdownMenuItem onClick={() => onPickImage?.('picture')}>
<ImagePlus className="size-4 mr-2" />
Change avatar
</DropdownMenuItem>
{onPasteUrl && (
<DropdownMenuItem onClick={() => onPasteUrl?.('picture')}>
<LinkIcon className="size-4 mr-2" />
Paste URL
</DropdownMenuItem>
)}
{metadata.picture && (
<DropdownMenuItem onClick={() => onRemoveAvatar?.()} className="text-destructive focus:text-destructive">
<XIcon className="size-4 mr-2" />
Remove avatar
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="relative shrink-0">
<Avatar className="shadow-sm size-24 border-4 border-background">
<AvatarImage src={metadata.picture} alt={displayName} className="object-cover" />
<AvatarFallback className="bg-primary/20 text-primary text-2xl font-bold">
{initial}
</AvatarFallback>
</Avatar>
</div>
)}
</div>
{/* Name */}
{editable ? (
<EditableInput
value={metadata.name ?? ''}
placeholder="Your name"
onChange={patch('name')}
className="text-xl font-bold"
/>
) : (
<h2 className="text-xl font-bold truncate">{displayName}</h2>
)}
{/* NIP-05 */}
{showNip05 && (editable || nip05) && (
<div className="flex items-center gap-1 mt-2 min-w-0 text-sm text-muted-foreground ml-2">
<CheckCircle2 className="size-3.5 text-primary shrink-0" />
{editable && nip05Focused ? (
<input
type="text"
value={nip05 ?? ''}
placeholder="you@domain.com"
autoFocus
onChange={(e) => patch('nip05')(e.target.value)}
onBlur={() => setNip05Focused(false)}
size={Math.max((nip05?.length ?? 0) + 1, 4)}
className={cn(editableBase, 'py-0.5 h-6 text-base md:text-sm text-muted-foreground border-primary bg-transparent')}
/>
) : (
<span
className={cn(
'inline-flex items-center gap-1 min-w-0',
editable && cn(editableBase, 'py-0.5 h-6 cursor-text'),
)}
onClick={() => editable && setNip05Focused(true)}
>
<span className="shrink min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
{nip05 ? formatNip05Display(nip05) : editable ? 'you@domain.com' : ''}
</span>
{nip05Domain && (
<ExternalFavicon url={`https://${nip05Domain}`} size={14} className="shrink-0" />
)}
</span>
)}
</div>
)}
{/* Bio — or, when `bioField` is `'website'`, a website input that
takes the bio's place entirely. */}
<div className="mt-2">
{bioField === 'website' ? (
editable ? (
<EditableInput
value={(metadata.website as string) ?? ''}
placeholder="https://your-website.com"
onChange={patch('website')}
className="text-sm"
/>
) : metadata.website ? (
<p className="text-sm text-muted-foreground leading-relaxed truncate">
{metadata.website}
</p>
) : null
) : editable ? (
<EditableTextarea
value={metadata.about ?? ''}
placeholder="Write a short bio…"
onChange={patch('about')}
/>
) : metadata.about ? (
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
<BioContent>{metadata.about}</BioContent>
</p>
) : null}
</div>
{/* Extra profile fields — collapsible, only when prop provided */}
{extraFields !== undefined && (
<Collapsible open={fieldsOpen} onOpenChange={setFieldsOpen} className="mt-3">
<div className="flex items-center justify-between">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" className="w-full justify-between px-0 h-auto hover:bg-transparent text-sm font-medium text-muted-foreground">
Profile Fields
<ChevronDown className="size-4 transition-transform duration-200 [[data-state=open]_&]:rotate-180" strokeWidth={4} />
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="space-y-2 pt-2">
{/* Website always first */}
<div className="grid grid-cols-[1fr,2fr] gap-2 items-center">
<span className="text-sm text-muted-foreground px-1">Website</span>
<Input
placeholder="https://yourwebsite.com"
value={(metadata.website as string) ?? ''}
onChange={(e) => patch('website')(e.target.value)}
className="h-8 text-base md:text-sm"
/>
</div>
{extraFields.map((field, i) => (
<div key={i} className="grid grid-cols-[1fr,2fr,auto] gap-2 items-center">
<Input
placeholder="Label"
value={field.label}
onChange={(e) => updateField(i, 'label', e.target.value)}
className="h-8 text-base md:text-sm"
/>
<Input
placeholder="Value or URL"
value={field.value}
onChange={(e) => updateField(i, 'value', e.target.value)}
className="h-8 text-base md:text-sm"
/>
<Button type="button" variant="ghost" size="icon" onClick={() => removeField(i)} className="h-8 w-8 text-destructive hover:text-destructive">
<Trash2 className="size-3.5" />
</Button>
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={addField} className="w-full h-8 text-xs">
<Plus className="size-3 mr-1" strokeWidth={4} /> Add Field
</Button>
</CollapsibleContent>
</Collapsible>
)}
</div>
{/* Badge showcase */}
{showBadges && (badgeRefs.length > 0 || badgesLoading) && (
<div className="px-4 pb-3">
<BadgeShowcaseGrid
items={badgeRefs.map((ref) => ({
aTag: ref.aTag,
pubkey: ref.pubkey,
identifier: ref.identifier,
badge: badgeMap.get(ref.aTag),
}))}
maxVisible={8}
thumbnailSize={44}
showEditButton={isOwnProfile}
isLoading={badgesLoading || defsLoading}
gridCols="grid-cols-4 sm:grid-cols-5"
/>
</div>
)}
</div>
);
}