Redesign profile edit form UI/UX + per-line sponsors

- Restructure EditProfileForm into clean, titled sections (Identity,
  Bitcoin & Payments, Skate Profile, Account) with gradient icon
  headers, consistent spacing, and a reusable FieldRow with inline
  icon + validation status.
- Sponsors is now a dynamic per-line list (add/remove rows, Enter to
  add) instead of a textarea; stored newline-separated. The profile
  renders each sponsor as its own badge.
- Lightning address placeholder is now generic (you@yourdomain.com) —
  no specific wallet names anywhere.
- Nicer image upload: click-the-thumbnail-to-upload with hover overlay,
  round avatar preview / wide banner preview.
- Sticky Save bar at the bottom of the form.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-02 20:06:32 +07:00
parent 11c67fcf22
commit 9599adaf0d
2 changed files with 407 additions and 332 deletions

View file

@ -18,7 +18,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Loader2, Upload, Zap, ShieldCheck, AlertCircle, Bitcoin, Award, MessageSquareLock } from 'lucide-react'; import { Loader2, Upload, Zap, ShieldCheck, AlertCircle, Bitcoin, Award, MessageSquareLock, UserRound, X, Plus, Bot } from 'lucide-react';
import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify'; import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useUploadFile } from '@/hooks/useUploadFile'; import { useUploadFile } from '@/hooks/useUploadFile';
@ -52,8 +52,10 @@ export const EditProfileForm: React.FC = () => {
const spInvalid = spTrimmed.length > 0 && !isValidSilentPayment(spTrimmed); const spInvalid = spTrimmed.length > 0 && !isValidSilentPayment(spTrimmed);
const spValid = isValidSilentPayment(spTrimmed); const spValid = isValidSilentPayment(spTrimmed);
// Sponsorships — freeform highlight (custom kind-0 field `sponsors`). // Sponsorships — one entry per line. Stored as newline-separated text in the
const [sponsors, setSponsors] = useState(''); // custom kind-0 field `sponsors`; edited here as a dynamic list of rows.
const [sponsorList, setSponsorList] = useState<string[]>(['']);
const cleanSponsors = sponsorList.map((s) => s.trim()).filter(Boolean);
// Cordn contact link — private (MLS-encrypted) group messaging you can own. // Cordn contact link — private (MLS-encrypted) group messaging you can own.
// A URL others can use to reach out to you on Cordn (cordn.net). // A URL others can use to reach out to you on Cordn (cordn.net).
@ -93,7 +95,10 @@ export const EditProfileForm: React.FC = () => {
if (typeof raw.noffer === 'string') setNoffer(raw.noffer); if (typeof raw.noffer === 'string') setNoffer(raw.noffer);
if (typeof metadata.lud16 === 'string') setLud16(metadata.lud16); if (typeof metadata.lud16 === 'string') setLud16(metadata.lud16);
if (typeof raw.bitcoin_silent_payment === 'string') setSilentPayment(raw.bitcoin_silent_payment); if (typeof raw.bitcoin_silent_payment === 'string') setSilentPayment(raw.bitcoin_silent_payment);
if (typeof raw.sponsors === 'string') setSponsors(raw.sponsors); if (typeof raw.sponsors === 'string') {
const lines = raw.sponsors.split('\n').map((s) => s.trim()).filter(Boolean);
setSponsorList(lines.length ? lines : ['']);
}
if (typeof raw.cordn === 'string') setCordn(raw.cordn); if (typeof raw.cordn === 'string') setCordn(raw.cordn);
} }
}, [metadata, form]); }, [metadata, form]);
@ -196,9 +201,9 @@ export const EditProfileForm: React.FC = () => {
delete data.bitcoin_silent_payment; delete data.bitcoin_silent_payment;
} }
// Sponsorships highlight (custom field). // Sponsorships highlight (custom field) — one entry per line.
if (sponsors.trim()) { if (cleanSponsors.length) {
data.sponsors = sponsors.trim(); data.sponsors = cleanSponsors.join('\n');
} else { } else {
delete data.sponsors; delete data.sponsors;
} }
@ -258,21 +263,28 @@ export const EditProfileForm: React.FC = () => {
} }
}; };
const saving = isPending || isUploading;
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* ── Section: Identity ── */}
<FormSection
icon={<UserRound className="h-4 w-4" />}
title="Identity"
description="How you show up across SkateSpots."
accent="from-[#ff0040] to-[#ff6600]"
>
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel>Display name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Your name" {...field} /> <Input placeholder="Your name" {...field} />
</FormControl> </FormControl>
<FormDescription>
This is your display name that will be displayed to others.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -285,45 +297,37 @@ export const EditProfileForm: React.FC = () => {
<FormItem> <FormItem>
<FormLabel>Bio</FormLabel> <FormLabel>Bio</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea placeholder="A few words about you and your skating…" className="resize-none min-h-[88px]" {...field} />
placeholder="Tell others about yourself"
className="resize-none"
{...field}
/>
</FormControl> </FormControl>
<FormDescription>
A short description about yourself.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormField <FormField
control={form.control} control={form.control}
name="picture" name="picture"
render={({ field }) => ( render={({ field }) => (
<ImageUploadField <ImageUploadField
field={field} field={field}
label="Profile Picture" label="Profile picture"
placeholder="https://example.com/profile.jpg" placeholder="https://…/avatar.jpg"
description="URL to your profile picture. You can upload an image or provide a URL." description="Upload or paste a square image."
previewType="square" previewType="square"
onUpload={(file) => uploadPicture(file, 'picture')} onUpload={(file) => uploadPicture(file, 'picture')}
/> />
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="banner" name="banner"
render={({ field }) => ( render={({ field }) => (
<ImageUploadField <ImageUploadField
field={field} field={field}
label="Banner Image" label="Banner image"
placeholder="https://example.com/banner.jpg" placeholder="https://…/banner.jpg"
description="URL to a wide banner image for your profile. You can upload an image or provide a URL." description="A wide image for the top of your profile."
previewType="wide" previewType="wide"
onUpload={(file) => uploadPicture(file, 'banner')} onUpload={(file) => uploadPicture(file, 'banner')}
/> />
@ -331,7 +335,7 @@ export const EditProfileForm: React.FC = () => {
/> />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormField <FormField
control={form.control} control={form.control}
name="website" name="website"
@ -341,216 +345,275 @@ export const EditProfileForm: React.FC = () => {
<FormControl> <FormControl>
<Input placeholder="https://yourwebsite.com" {...field} /> <Input placeholder="https://yourwebsite.com" {...field} />
</FormControl> </FormControl>
<FormDescription>
Your personal website or social media link.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="nip05" name="nip05"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>NIP-05 Identifier</FormLabel> <FormLabel>NIP-05 identifier</FormLabel>
<FormControl> <FormControl>
<Input placeholder="you@example.com" {...field} /> <Input placeholder="you@example.com" {...field} />
</FormControl> </FormControl>
<FormDescription>
Your verified Nostr identifier.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div> </div>
</FormSection>
{/* CLINK Offer — self-custodial Bitcoin tip code (custom metadata field) */} {/* ── Section: Bitcoin & Payments ── */}
<div className="space-y-2 rounded-lg border border-indigo-500/30 bg-indigo-500/5 p-4"> <FormSection
<Label htmlFor="profile-noffer" className="flex items-center gap-2 text-indigo-600 dark:text-indigo-400"> icon={<Bitcoin className="h-4 w-4" />}
<Zap className="h-4 w-4" /> title="Bitcoin & Payments"
Bitcoin Tip Code (CLINK) <span className="text-xs font-normal text-muted-foreground">(optional)</span> description="Let people zap, tip and pay you directly — no middleman."
</Label> accent="from-amber-500 to-orange-500"
<Input
id="profile-noffer"
placeholder="noffer1..."
value={noffer}
onChange={(e) => setNoffer(e.target.value)}
spellCheck={false}
autoCapitalize="none"
className="font-mono text-sm"
/>
{nofferInvalid ? (
<p className="flex items-center gap-1.5 text-xs text-red-500">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
That doesn't look like a valid <code className="mx-1">noffer1…</code> code.
</p>
) : nofferValid ? (
<p className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
Valid — a "Tip in Bitcoin" button will show on your profile.
</p>
) : (
<p className="text-xs text-muted-foreground">
Paste a self-custodial CLINK payment code (from e.g. ShockWallet / Lightning.Pub)
so anyone can tip you directly in Bitcoin over Nostr — no middleman.{' '}
<a
href="https://clinkme.dev/specs.html"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-500 underline hover:text-indigo-400"
> >
Learn more {/* Lightning address */}
</a> <FieldRow
</p>
)}
</div>
{/* Lightning address (LUD-16) */}
<div className="space-y-2 rounded-lg border border-yellow-500/30 bg-yellow-500/5 p-4">
<Label htmlFor="profile-lud16" className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
<Zap className="h-4 w-4" />
Bitcoin Lightning Address <span className="text-xs font-normal text-muted-foreground">(optional)</span>
</Label>
<Input
id="profile-lud16" id="profile-lud16"
placeholder="you@walletofsatoshi.com" icon={<Zap className="h-4 w-4" />}
iconClass="text-yellow-500 bg-yellow-500/10"
label="Lightning address"
value={lud16} value={lud16}
onChange={(e) => setLud16(e.target.value)} onChange={setLud16}
spellCheck={false} placeholder="you@yourdomain.com"
autoCapitalize="none" status={
className="font-mono text-sm" lud16Invalid
? { kind: 'error', text: 'Use an email-like address, e.g. you@yourdomain.com.' }
: lud16Trimmed
? { kind: 'ok', text: 'Powers the “Zap” button on your profile.' }
: { kind: 'hint', text: 'Instant Bitcoin tips. Powers the “Zap” button on your profile.' }
}
/> />
{lud16Invalid ? (
<p className="flex items-center gap-1.5 text-xs text-red-500">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
Use an email-like address such as <code className="mx-1">you@getalby.com</code>.
</p>
) : (
<p className="text-xs text-muted-foreground">
Your Lightning address lets anyone zap you instantly. Powers the "Zap" button on your profile.
</p>
)}
</div>
{/* Bitcoin Silent Payment address (BIP-352) */} {/* Silent Payment address */}
<div className="space-y-2 rounded-lg border border-orange-500/30 bg-orange-500/5 p-4"> <FieldRow
<Label htmlFor="profile-sp" className="flex items-center gap-2 text-orange-600 dark:text-orange-400">
<Bitcoin className="h-4 w-4" />
Bitcoin Silent Payment Address <span className="text-xs font-normal text-muted-foreground">(optional)</span>
</Label>
<Input
id="profile-sp" id="profile-sp"
placeholder="sp1q..." icon={<Bitcoin className="h-4 w-4" />}
iconClass="text-orange-500 bg-orange-500/10"
label="Silent Payment address"
value={silentPayment} value={silentPayment}
onChange={(e) => setSilentPayment(e.target.value)} onChange={setSilentPayment}
spellCheck={false} placeholder="sp1q…"
autoCapitalize="none" status={
className="font-mono text-sm" spInvalid
? { kind: 'error', text: 'Should start with “sp1” and be quite long.' }
: spValid
? { kind: 'ok', text: 'Valid — a reusable, private on-chain address.' }
: { kind: 'hint', text: 'Reusable static on-chain address (BIP-352). Share once; every payment is unlinkable.' }
}
/> />
{spInvalid ? (
<p className="flex items-center gap-1.5 text-xs text-red-500">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
A Silent Payment address starts with <code className="mx-1">sp1</code> and is quite long.
</p>
) : spValid ? (
<p className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
Valid — a reusable, private on-chain address others can pay repeatedly.
</p>
) : (
<p className="text-xs text-muted-foreground">
A reusable static on-chain address (BIP-352). Share once; every payment lands at a fresh, unlinkable output.
</p>
)}
</div>
{/* Sponsorships */} {/* CLINK tip code */}
<div className="space-y-2 rounded-lg border border-pink-500/30 bg-pink-500/5 p-4"> <FieldRow
<Label htmlFor="profile-sponsors" className="flex items-center gap-2 text-pink-600 dark:text-pink-400"> id="profile-noffer"
<Award className="h-4 w-4" /> icon={<Zap className="h-4 w-4" />}
Sponsors & Support <span className="text-xs font-normal text-muted-foreground">(optional)</span> iconClass="text-indigo-500 bg-indigo-500/10"
</Label> label="CLINK tip code"
<Textarea value={noffer}
id="profile-sponsors" onChange={setNoffer}
placeholder="Flow, Independent Trucks, your local shop… whoever backs your skating." placeholder="noffer1…"
value={sponsors} status={
onChange={(e) => setSponsors(e.target.value)} nofferInvalid
className="resize-none text-sm" ? { kind: 'error', text: 'That doesn’t look like a valid noffer1… code.' }
rows={2} : nofferValid
? { kind: 'ok', text: 'Valid — a “Tip in Bitcoin” button will show on your profile.' }
: { kind: 'hint', text: 'Self-custodial CLINK code so anyone can tip you over Nostr — no middleman.' }
}
/> />
</FormSection>
{/* ── Section: Skate profile ── */}
<FormSection
icon={<Award className="h-4 w-4" />}
title="Skate Profile"
description="Reppin’ your sponsors and how the crew reaches you."
accent="from-pink-500 to-fuchsia-500"
>
{/* Sponsors — one per line */}
<div className="space-y-2.5">
<Label className="flex items-center gap-2 text-sm font-semibold">
<Award className="h-4 w-4 text-pink-500" />
Sponsors &amp; support
<span className="text-xs font-normal text-muted-foreground">(one per line)</span>
</Label>
<div className="space-y-2">
{sponsorList.map((value, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-pink-500/60 text-sm font-black w-4 text-center flex-shrink-0">•</span>
<Input
value={value}
placeholder={i === 0 ? 'e.g. Independent Trucks' : 'Add another sponsor…'}
onChange={(e) => {
const next = [...sponsorList];
next[i] = e.target.value;
setSponsorList(next);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (i === sponsorList.length - 1 && value.trim()) {
setSponsorList([...sponsorList, '']);
}
}
}}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 flex-shrink-0 text-muted-foreground hover:text-red-500"
onClick={() => {
const next = sponsorList.filter((_, idx) => idx !== i);
setSponsorList(next.length ? next : ['']);
}}
aria-label="Remove sponsor"
disabled={sponsorList.length === 1 && !value}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5 border-pink-500/40 text-pink-600 dark:text-pink-400 hover:bg-pink-500/10"
onClick={() => setSponsorList([...sponsorList, ''])}
>
<Plus className="h-3.5 w-3.5" />
Add sponsor
</Button>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Highlight the brands, shops or people who sponsor or support you. Shown on your profile. Brands, shops or people who back your skating. Each line shows as its own badge on your profile.
</p> </p>
</div> </div>
{/* Cordn contact link */} {/* Cordn contact link */}
<div className="space-y-2 rounded-lg border border-teal-500/30 bg-teal-500/5 p-4"> <FieldRow
<Label htmlFor="profile-cordn" className="flex items-center gap-2 text-teal-600 dark:text-teal-400">
<MessageSquareLock className="h-4 w-4" />
Cordn Contact Link <span className="text-xs font-normal text-muted-foreground">(optional)</span>
</Label>
<Input
id="profile-cordn" id="profile-cordn"
placeholder="https://cordn.net/..." icon={<MessageSquareLock className="h-4 w-4" />}
iconClass="text-teal-500 bg-teal-500/10"
label="Cordn contact link"
value={cordn} value={cordn}
onChange={(e) => setCordn(e.target.value)} onChange={setCordn}
spellCheck={false} placeholder="https://cordn.net/…"
autoCapitalize="none" status={
className="font-mono text-sm" cordnInvalid
? { kind: 'error', text: 'Paste a full link starting with https://.' }
: { kind: 'hint', text: 'A link others use to reach you on Cordn — private, encrypted messaging you own.' }
}
/> />
{cordnInvalid ? ( </FormSection>
<p className="flex items-center gap-1.5 text-xs text-red-500">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
Paste a full link starting with <code className="mx-1">https://</code>.
</p>
) : (
<p className="text-xs text-muted-foreground">
A link others can use to reach you on{' '}
<a href="https://cordn.net" target="_blank" rel="noopener noreferrer" className="text-teal-500 underline hover:text-teal-400">Cordn</a>
{' '}— private, encrypted group messaging you own.
</p>
)}
</div>
{/* ── Section: Account ── */}
<FormSection
icon={<Bot className="h-4 w-4" />}
title="Account"
description="Extra account settings."
accent="from-slate-500 to-gray-500"
>
<FormField <FormField
control={form.control} control={form.control}
name="bot" name="bot"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> <FormItem className="flex flex-row items-center justify-between rounded-xl border p-4 bg-muted/20">
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel className="text-base">Bot Account</FormLabel> <FormLabel className="text-base">Bot account</FormLabel>
<FormDescription> <FormDescription>Mark this account as automated or a bot.</FormDescription>
Mark this account as automated or a bot.
</FormDescription>
</div> </div>
<FormControl> <FormControl>
<Switch <Switch checked={field.value} onCheckedChange={field.onChange} />
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
</FormSection>
<Button {/* ── Sticky save bar ── */}
type="submit" <div className="sticky bottom-0 -mx-1 px-1 py-3 bg-gradient-to-t from-background via-background to-transparent">
className="w-full md:w-auto" <Button type="submit" className="w-full h-11 font-bold" disabled={saving}>
disabled={isPending || isUploading} {saving ? (
> <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Saving…</>
{(isPending || isUploading) && ( ) : (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> 'Save Profile'
)} )}
Save Profile
</Button> </Button>
</div>
</form> </form>
</Form> </Form>
); );
}; };
/** A titled, visually-grouped section of the profile form. */
const FormSection: React.FC<{
icon: React.ReactNode;
title: string;
description?: string;
accent: string; // tailwind gradient stops, e.g. "from-x to-y"
children: React.ReactNode;
}> = ({ icon, title, description, accent, children }) => (
<section className="rounded-2xl border bg-card/40 overflow-hidden">
<div className="flex items-center gap-3 px-4 sm:px-5 py-4 border-b bg-muted/30">
<div className={`flex-shrink-0 h-9 w-9 rounded-xl bg-gradient-to-br ${accent} text-white flex items-center justify-center shadow-sm`}>
{icon}
</div>
<div className="min-w-0">
<h3 className="text-base font-bold leading-tight">{title}</h3>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
</div>
<div className="p-4 sm:p-5 space-y-5">{children}</div>
</section>
);
/** A single labeled input row with an icon and inline validation status. */
const FieldRow: React.FC<{
id: string;
icon: React.ReactNode;
iconClass: string;
label: string;
value: string;
onChange: (v: string) => void;
placeholder: string;
status: { kind: 'hint' | 'ok' | 'error'; text: string };
}> = ({ id, icon, iconClass, label, value, onChange, placeholder, status }) => (
<div className="space-y-2">
<Label htmlFor={id} className="flex items-center gap-2 text-sm font-semibold">
<span className={`h-6 w-6 rounded-lg flex items-center justify-center ${iconClass}`}>{icon}</span>
{label}
</Label>
<Input
id={id}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
autoCapitalize="none"
className="font-mono text-sm"
/>
{status.kind === 'error' ? (
<p className="flex items-start gap-1.5 text-xs text-red-500">
<AlertCircle className="h-3.5 w-3.5 shrink-0 mt-px" />
{status.text}
</p>
) : status.kind === 'ok' ? (
<p className="flex items-start gap-1.5 text-xs text-green-600 dark:text-green-400">
<ShieldCheck className="h-3.5 w-3.5 shrink-0 mt-px" />
{status.text}
</p>
) : (
<p className="text-xs text-muted-foreground">{status.text}</p>
)}
</div>
);
// Reusable component for image upload fields // Reusable component for image upload fields
interface ImageUploadFieldProps { interface ImageUploadFieldProps {
field: { field: {
@ -579,17 +642,29 @@ const ImageUploadField: React.FC<ImageUploadFieldProps> = ({
return ( return (
<FormItem> <FormItem>
<FormLabel>{label}</FormLabel> <FormLabel>{label}</FormLabel>
<div className="flex flex-col gap-2"> <div className="flex items-start gap-3">
<FormControl> {/* Preview thumbnail — click to upload */}
<Input <button
placeholder={placeholder} type="button"
name={field.name} onClick={() => fileInputRef.current?.click()}
value={field.value ?? ''} className={`group relative flex-shrink-0 overflow-hidden border-2 border-dashed border-muted-foreground/25 hover:border-primary/50 transition-colors bg-muted/30 flex items-center justify-center ${
onChange={e => field.onChange(e.target.value)} previewType === 'square' ? 'h-16 w-16 rounded-full' : 'h-16 w-28 rounded-lg'
onBlur={field.onBlur} }`}
/> aria-label={`Upload ${label}`}
</FormControl> >
<div className="flex items-center gap-2"> {field.value ? (
<>
<img src={field.value} alt={`${label} preview`} className="h-full w-full object-cover" />
<span className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Upload className="h-4 w-4 text-white" />
</span>
</>
) : (
<Upload className="h-5 w-5 text-muted-foreground/50 group-hover:text-primary/60 transition-colors" />
)}
</button>
<div className="flex-1 min-w-0 space-y-1.5">
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
@ -597,34 +672,22 @@ const ImageUploadField: React.FC<ImageUploadFieldProps> = ({
className="hidden" className="hidden"
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) onUpload(file);
onUpload(file);
}
}} }}
/> />
<Button <FormControl>
type="button" <Input
variant="outline" placeholder={placeholder}
size="sm" name={field.name}
onClick={() => fileInputRef.current?.click()} value={field.value ?? ''}
> onChange={e => field.onChange(e.target.value)}
<Upload className="h-4 w-4 mr-2" /> onBlur={field.onBlur}
Upload Image className="text-sm"
</Button>
{field.value && (
<div className={`h-10 ${previewType === 'square' ? 'w-10' : 'w-24'} rounded overflow-hidden`}>
<img
src={field.value}
alt={`${label} preview`}
className="h-full w-full object-cover"
/> />
</div> </FormControl>
)} <FormDescription className="text-xs">{description}</FormDescription>
</div> </div>
</div> </div>
<FormDescription>
{description}
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); );

View file

@ -66,7 +66,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
const nofferCode = typeof noffer === 'string' ? noffer : undefined; const nofferCode = typeof noffer === 'string' ? noffer : undefined;
// Extra custom profile fields. // Extra custom profile fields.
const silentPayment = typeof raw?.bitcoin_silent_payment === 'string' ? raw.bitcoin_silent_payment : undefined; const silentPayment = typeof raw?.bitcoin_silent_payment === 'string' ? raw.bitcoin_silent_payment : undefined;
const sponsors = typeof raw?.sponsors === 'string' ? raw.sponsors.trim() : undefined; const sponsorItems = typeof raw?.sponsors === 'string'
? raw.sponsors.split('\n').map((s) => s.trim()).filter(Boolean)
: [];
const cordnUrl = typeof raw?.cordn === 'string' ? raw.cordn : undefined; const cordnUrl = typeof raw?.cordn === 'string' ? raw.cordn : undefined;
const [spCopied, setSpCopied] = useState(false); const [spCopied, setSpCopied] = useState(false);
@ -484,8 +486,8 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</CardContent> </CardContent>
</Card> </Card>
{/* Sponsors Card */} {/* Sponsors Card — one badge per line */}
{sponsors && ( {sponsorItems.length > 0 && (
<Card className="bg-gradient-to-br from-pink-500/10 to-transparent border-2 border-pink-500/30"> <Card className="bg-gradient-to-br from-pink-500/10 to-transparent border-2 border-pink-500/30">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
@ -494,9 +496,19 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-gray-300 whitespace-pre-wrap break-words [overflow-wrap:anywhere]"> <div className="flex flex-col gap-2">
{sponsors} {sponsorItems.map((s, i) => (
</p> <div
key={i}
className="flex items-center gap-2.5 px-3 py-2 rounded-lg bg-pink-500/10 border border-pink-500/20"
>
<span className="h-1.5 w-1.5 rounded-full bg-pink-400 flex-shrink-0" />
<span className="text-sm font-semibold text-gray-200 break-words [overflow-wrap:anywhere]">
{s}
</span>
</div>
))}
</div>
</CardContent> </CardContent>
</Card> </Card>
)} )}