Browse Source

make tags draggable

imwald
Silberengel 3 weeks ago
parent
commit
cb6eb6eede
  1. 95
      src/components/EditorSortableList/index.tsx
  2. 349
      src/pages/secondary/ProfileEditorPage/index.tsx

95
src/components/EditorSortableList/index.tsx

@ -0,0 +1,95 @@
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent
} from '@dnd-kit/core'
import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers'
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
export function EditorSortableList({
itemIds,
onReorder,
className,
children
}: {
itemIds: string[]
onReorder: (fromIndex: number, toIndex: number) => void
className?: string
children: React.ReactNode
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = itemIds.indexOf(String(active.id))
const newIndex = itemIds.indexOf(String(over.id))
if (oldIndex !== -1 && newIndex !== -1) onReorder(oldIndex, newIndex)
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
<div className={className}>{children}</div>
</SortableContext>
</DndContext>
)
}
export function SortableEditorRow({
id,
className,
children
}: {
id: string
className?: string
children: React.ReactNode
}) {
const { t } = useTranslation()
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1
}
return (
<div ref={setNodeRef} style={style} className={cn('flex gap-1 items-start min-w-0', className)}>
<button
type="button"
className="mt-0.5 shrink-0 cursor-grab touch-none rounded p-1.5 text-muted-foreground hover:bg-muted active:cursor-grabbing"
style={{ touchAction: 'none' }}
aria-label={t('profileEditorDragToReorder', { defaultValue: 'Drag to reorder' })}
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
<div className="min-w-0 flex-1">{children}</div>
</div>
)
}

349
src/pages/secondary/ProfileEditorPage/index.tsx

@ -34,7 +34,9 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { canUseNostrBuildThumb, toNostrBuildThumbUrl } from '@/lib/nostr-build' import { canUseNostrBuildThumb, toNostrBuildThumbUrl } from '@/lib/nostr-build'
import { isVideo } from '@/lib/url' import { isVideo } from '@/lib/url'
import { EditorSortableList, SortableEditorRow } from '@/components/EditorSortableList'
import PaymentMethodRow from '@/components/ProfileEditor/PaymentMethodRow' import PaymentMethodRow from '@/components/ProfileEditor/PaymentMethodRow'
import { arrayMove } from '@dnd-kit/sortable'
import { PAYTO_EDITOR_OTHER_OPTION } from '@/lib/payto' import { PAYTO_EDITOR_OTHER_OPTION } from '@/lib/payto'
import { normalizePaypalAuthority } from '@/lib/payto-paypal-url' import { normalizePaypalAuthority } from '@/lib/payto-paypal-url'
import { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react' import { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react'
@ -134,7 +136,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const [paymentInfoEvent, setPaymentInfoEvent] = useState<Event | null>(null) const [paymentInfoEvent, setPaymentInfoEvent] = useState<Event | null>(null)
const [paymentInfoEditOpen, setPaymentInfoEditOpen] = useState(false) const [paymentInfoEditOpen, setPaymentInfoEditOpen] = useState(false)
const [paymentInfoEditContent, setPaymentInfoEditContent] = useState('') const [paymentInfoEditContent, setPaymentInfoEditContent] = useState('')
const [paymentInfoEditMethods, setPaymentInfoEditMethods] = useState<Array<{ type: string; authority: string }>>([]) const [paymentInfoEditMethods, setPaymentInfoEditMethods] = useState<EditorPaymentMethodRow[]>([])
const [paymentInfoShowFullJson, setPaymentInfoShowFullJson] = useState(false) const [paymentInfoShowFullJson, setPaymentInfoShowFullJson] = useState(false)
const [savingPaymentInfo, setSavingPaymentInfo] = useState(false) const [savingPaymentInfo, setSavingPaymentInfo] = useState(false)
const savingPaymentInfoRef = useRef(false) const savingPaymentInfoRef = useRef(false)
@ -142,7 +144,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const [savingFullProfile, setSavingFullProfile] = useState(false) const [savingFullProfile, setSavingFullProfile] = useState(false)
const [refreshingCache, setRefreshingCache] = useState(false) const [refreshingCache, setRefreshingCache] = useState(false)
/** Single source of truth: all profile tags (excluding auto-managed client/alt). */ /** Single source of truth: all profile tags (excluding auto-managed client/alt). */
const [profileTags, setProfileTags] = useState<string[][]>([]) const [profileTagRows, setProfileTagRows] = useState<EditorTagRow[]>([])
const [imageUrlField, setImageUrlField] = useState<'picture' | 'banner' | null>(null) const [imageUrlField, setImageUrlField] = useState<'picture' | 'banner' | null>(null)
const [imageUrlDraft, setImageUrlDraft] = useState('') const [imageUrlDraft, setImageUrlDraft] = useState('')
const [tagToAdd, setTagToAdd] = useState<string>(ADD_TAG_OPTIONS[0]) const [tagToAdd, setTagToAdd] = useState<string>(ADD_TAG_OPTIONS[0])
@ -152,9 +154,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
[account] [account]
) )
/** Derived from profileTags so uploaders and visual preview stay in sync. */ /** Derived from profile tag rows so uploaders and visual preview stay in sync. */
const avatar = profileTags.find((t) => t[0] === 'picture')?.[1] ?? '' const avatar = profileTagRows.find((r) => r.tag[0] === 'picture')?.tag[1] ?? ''
const banner = profileTags.find((t) => t[0] === 'banner')?.[1] ?? '' const banner = profileTagRows.find((r) => r.tag[0] === 'banner')?.tag[1] ?? ''
useEffect(() => { useEffect(() => {
mountedRef.current = true mountedRef.current = true
@ -169,7 +171,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
// Rebuild tag list when the stored profile event changes — not while the user is editing. // Rebuild tag list when the stored profile event changes — not while the user is editing.
useEffect(() => { useEffect(() => {
if (profileFormSyncLocked) return if (profileFormSyncLocked) return
setProfileTags(buildTagListFromEvent(profileEvent ?? null)) setProfileTagRows(tagRowsFromTags(buildTagListFromEvent(profileEvent ?? null)))
}, [profileEvent, profileFormSyncLocked]) }, [profileEvent, profileFormSyncLocked])
// Sync full-event JSON editor (same guard as tag list). // Sync full-event JSON editor (same guard as tag list).
@ -191,36 +193,47 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
// ─── Tag list helpers ──────────────────────────────────────────────────────── // ─── Tag list helpers ────────────────────────────────────────────────────────
const updateTagValue = (idx: number, value: string) => { const updateTagValue = (id: string, value: string) => {
setProfileTags((prev) => prev.map((t, i) => (i === idx ? [t[0], value, ...t.slice(2)] : t))) setProfileTagRows((prev) =>
prev.map((row) =>
row.id === id ? { ...row, tag: [row.tag[0], value, ...row.tag.slice(2)] } : row
)
)
setHasChanged(true) setHasChanged(true)
} }
const updateTagName = (idx: number, name: string) => { const updateTagName = (id: string, name: string) => {
// Prevent renaming to a name that may only appear once and is already occupied.
if (AT_MOST_ONE_NAMES.includes(name)) { if (AT_MOST_ONE_NAMES.includes(name)) {
const existingIdx = profileTags.findIndex((t) => t[0] === name) const existing = profileTagRows.find((row) => row.tag[0] === name)
if (existingIdx !== -1 && existingIdx !== idx) { if (existing && existing.id !== id) {
toast.error(t('profileEditorDuplicateSingleton', { defaultValue: `"${name}" may only appear once` })) toast.error(t('profileEditorDuplicateSingleton', { defaultValue: `"${name}" may only appear once` }))
return return
} }
} }
setProfileTags((prev) => prev.map((t, i) => (i === idx ? [name, t[1] ?? '', ...t.slice(2)] : t))) setProfileTagRows((prev) =>
prev.map((row) =>
row.id === id ? { ...row, tag: [name, row.tag[1] ?? '', ...row.tag.slice(2)] } : row
)
)
setHasChanged(true)
}
const removeTag = (id: string) => {
setProfileTagRows((prev) => prev.filter((row) => row.id !== id))
setHasChanged(true) setHasChanged(true)
} }
const removeTag = (idx: number) => { const reorderProfileTagRows = (fromIndex: number, toIndex: number) => {
setProfileTags((prev) => prev.filter((_, i) => i !== idx)) setProfileTagRows((prev) => arrayMove(prev, fromIndex, toIndex))
setHasChanged(true) setHasChanged(true)
} }
const addTag = (name = '', value = '') => { const addTag = (name = '', value = '') => {
// Prevent adding a second row for any "at most one" tag. if (name && AT_MOST_ONE_NAMES.includes(name) && profileTagRows.some((row) => row.tag[0] === name)) {
if (name && AT_MOST_ONE_NAMES.includes(name) && profileTags.some((t) => t[0] === name)) {
toast.error(t('profileEditorDuplicateSingleton', { defaultValue: `"${name}" may only appear once` })) toast.error(t('profileEditorDuplicateSingleton', { defaultValue: `"${name}" may only appear once` }))
return return
} }
setProfileTags((prev) => [...prev, [name, value]]) setProfileTagRows((prev) => [...prev, { id: newEditorId(), tag: [name, value] }])
setHasChanged(true) setHasChanged(true)
} }
@ -239,14 +252,15 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
setPaymentInfoEditMethods( setPaymentInfoEditMethods(
paytoTags.length > 0 paytoTags.length > 0
? paytoTags.map((tag) => ({ ? paytoTags.map((tag) => ({
id: newEditorId(),
type: (tag[1] as string) || 'lightning', type: (tag[1] as string) || 'lightning',
authority: (tag[2] as string) || '' authority: (tag[2] as string) || ''
})) }))
: [{ type: 'lightning', authority: '' }] : [{ id: newEditorId(), type: 'lightning', authority: '' }]
) )
} else { } else {
setPaymentInfoEditContent('{}') setPaymentInfoEditContent('{}')
setPaymentInfoEditMethods([{ type: 'lightning', authority: '' }]) setPaymentInfoEditMethods([{ id: newEditorId(), type: 'lightning', authority: '' }])
} }
setPaymentInfoShowFullJson(false) setPaymentInfoShowFullJson(false)
setPaymentInfoEditOpen(true) setPaymentInfoEditOpen(true)
@ -302,7 +316,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
]) ])
if (profileEvt) { if (profileEvt) {
await updateProfileEvent(profileEvt) await updateProfileEvent(profileEvt)
setProfileTags(buildTagListFromEvent(profileEvt)) setProfileTagRows(tagRowsFromTags(buildTagListFromEvent(profileEvt)))
setProfileEventJson(JSON.stringify(profileEvt, null, 2)) setProfileEventJson(JSON.stringify(profileEvt, null, 2))
setHasChanged(false) setHasChanged(false)
} }
@ -361,38 +375,44 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const openImageUrlEditor = (field: 'picture' | 'banner') => { const openImageUrlEditor = (field: 'picture' | 'banner') => {
setImageUrlField(field) setImageUrlField(field)
setImageUrlDraft(profileTags.find((t) => t[0] === field)?.[1] ?? '') setImageUrlDraft(profileTagRows.find((r) => r.tag[0] === field)?.tag[1] ?? '')
} }
const applyImageUrlDraft = () => { const applyImageUrlDraft = () => {
if (!imageUrlField) return if (!imageUrlField) return
const v = imageUrlDraft.trim() const v = imageUrlDraft.trim()
setProfileTags((prev) => { setProfileTagRows((prev) => {
const idx = prev.findIndex((t) => t[0] === imageUrlField) const idx = prev.findIndex((row) => row.tag[0] === imageUrlField)
return idx >= 0 return idx >= 0
? prev.map((t, i) => (i === idx ? [imageUrlField, v, ...t.slice(2)] : t)) ? prev.map((row, i) =>
: [...prev, [imageUrlField, v]] i === idx ? { ...row, tag: [imageUrlField, v, ...row.tag.slice(2)] } : row
)
: [...prev, { id: newEditorId(), tag: [imageUrlField, v] }]
}) })
setHasChanged(true) setHasChanged(true)
setImageUrlField(null) setImageUrlField(null)
} }
const onBannerUploadSuccess = ({ url }: { url: string }) => { const onBannerUploadSuccess = ({ url }: { url: string }) => {
setProfileTags((prev) => { setProfileTagRows((prev) => {
const idx = prev.findIndex((t) => t[0] === 'banner') const idx = prev.findIndex((row) => row.tag[0] === 'banner')
return idx >= 0 return idx >= 0
? prev.map((t, i) => (i === idx ? ['banner', url, ...t.slice(2)] : t)) ? prev.map((row, i) =>
: [...prev, ['banner', url]] i === idx ? { ...row, tag: ['banner', url, ...row.tag.slice(2)] } : row
)
: [...prev, { id: newEditorId(), tag: ['banner', url] }]
}) })
setHasChanged(true) setHasChanged(true)
} }
const onAvatarUploadSuccess = ({ url }: { url: string }) => { const onAvatarUploadSuccess = ({ url }: { url: string }) => {
setProfileTags((prev) => { setProfileTagRows((prev) => {
const idx = prev.findIndex((t) => t[0] === 'picture') const idx = prev.findIndex((row) => row.tag[0] === 'picture')
return idx >= 0 return idx >= 0
? prev.map((t, i) => (i === idx ? ['picture', url, ...t.slice(2)] : t)) ? prev.map((row, i) =>
: [...prev, ['picture', url]] i === idx ? { ...row, tag: ['picture', url, ...row.tag.slice(2)] } : row
)
: [...prev, { id: newEditorId(), tag: ['picture', url] }]
}) })
setHasChanged(true) setHasChanged(true)
} }
@ -407,7 +427,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const savePromise = (async () => { const savePromise = (async () => {
try { try {
// Strip empty/incomplete rows, trim whitespace. // Strip empty/incomplete rows, trim whitespace.
const validTags = profileTags const validTags = profileTagRows
.map((row) => row.tag)
.filter((t) => { .filter((t) => {
if (!Array.isArray(t) || !(t[0] ?? '').trim()) return false if (!Array.isArray(t) || !(t[0] ?? '').trim()) return false
const name = (t[0] ?? '').trim() const name = (t[0] ?? '').trim()
@ -427,9 +448,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
return [name, v1, ...t.slice(2)] return [name, v1, ...t.slice(2)]
}) })
// Sort alphabetically by tag name (stable: same-name tags keep their relative order). const orderedTags = validTags
const sortedTags = [...validTags]
.sort((a, b) => a[0].localeCompare(b[0]))
// Enforce at-most-one uniqueness: keep only the first occurrence. // Enforce at-most-one uniqueness: keep only the first occurrence.
.filter((() => { .filter((() => {
const seen = new Set<string>() const seen = new Set<string>()
@ -441,10 +460,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
} }
})()) })())
// Derive content JSON: first occurrence of each known field.
const content: Record<string, string> = {} const content: Record<string, string> = {}
const seenContent = new Set<string>() const seenContent = new Set<string>()
for (const tag of sortedTags) { for (const tag of orderedTags) {
const name = tag[0] const name = tag[0]
if (name === 'bot') continue if (name === 'bot') continue
if (DISPLAY_ORDER.includes(name) && !seenContent.has(name)) { if (DISPLAY_ORDER.includes(name) && !seenContent.has(name)) {
@ -455,7 +473,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
// Keep displayName alias for backward compatibility. // Keep displayName alias for backward compatibility.
if (content['display_name']) content['displayName'] = content['display_name'] if (content['display_name']) content['displayName'] = content['display_name']
const draft = createProfileDraftEvent(JSON.stringify(content), sortedTags) const draft = createProfileDraftEvent(JSON.stringify(content), orderedTags)
const published = await publish(draft) const published = await publish(draft)
await updateProfileEvent(published) await updateProfileEvent(published)
if (!mountedRef.current) return if (!mountedRef.current) return
@ -587,11 +605,16 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('profileEditorTagListHint', { {t('profileEditorTagListHint', {
defaultValue: defaultValue:
'All profile fields as tags. On save, tags are sorted by name; the first of each known field also populates the content JSON.' 'All profile fields as tags. Drag rows to reorder; tag order is saved on publish. The first of each known field also populates the content JSON.'
})} })}
</p> </p>
<div className="space-y-1.5"> <EditorSortableList
{profileTags.map((tag, idx) => { itemIds={profileTagRows.map((row) => row.id)}
onReorder={reorderProfileTagRows}
className="space-y-1.5"
>
{profileTagRows.map((row) => {
const tag = row.tag
const name = tag[0] ?? '' const name = tag[0] ?? ''
const value = tag[1] ?? '' const value = tag[1] ?? ''
const isSingleton = SINGLETON_NAMES.includes(name) const isSingleton = SINGLETON_NAMES.includes(name)
@ -601,78 +624,76 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
if (isPic || isBan) { if (isPic || isBan) {
return ( return (
<ProfileImageTagRow <SortableEditorRow key={row.id} id={row.id}>
key={idx} <ProfileImageTagRow
tagName={name as 'picture' | 'banner'} tagName={name as 'picture' | 'banner'}
value={value} value={value}
onEdit={() => openImageUrlEditor(name as 'picture' | 'banner')} onEdit={() => openImageUrlEditor(name as 'picture' | 'banner')}
onInsertThumb={() => { onInsertThumb={() => {
const next = insertNostrBuildThumbUrl(value) const next = insertNostrBuildThumbUrl(value)
if (next) { if (next) {
setProfileTags((prev) => updateTagValue(row.id, next)
prev.map((t, i) => (i === idx ? [name, next, ...t.slice(2)] : t)) }
) }}
setHasChanged(true) showThumbButton={isPic && canInsertNostrBuildThumb(value)}
} t={t}
}} />
showThumbButton={isPic && canInsertNostrBuildThumb(value)} </SortableEditorRow>
t={t}
/>
) )
} }
return ( return (
<div key={idx} className="flex flex-wrap gap-2 items-start min-w-0"> <SortableEditorRow key={row.id} id={row.id}>
{/* Tag name: fixed label for known, editable input for custom */} <div className="flex flex-wrap gap-2 items-start min-w-0">
<div className="w-full shrink-0 sm:w-28 sm:flex-none"> <div className="w-full shrink-0 sm:w-28 sm:flex-none">
{isKnown ? ( {isKnown ? (
<p <p
className="text-xs font-medium text-muted-foreground pt-2 truncate" className="text-xs font-medium text-muted-foreground pt-2 truncate"
title={TAG_LABELS[name] || name} title={TAG_LABELS[name] || name}
> >
{TAG_LABELS[name] || name} {TAG_LABELS[name] || name}
</p> </p>
) : (
<Input
value={name}
placeholder={t('Tag name')}
className="font-mono text-xs h-8"
onChange={(e) => updateTagName(row.id, e.target.value)}
/>
)}
</div>
{name === 'about' ? (
<Textarea
className="flex-1 text-sm min-h-[5rem] resize-y"
value={value}
onChange={(e) => updateTagValue(row.id, e.target.value)}
/>
) : ( ) : (
<Input <Input
value={name} className="flex-1 font-mono text-sm"
placeholder={t('Tag name')} value={value}
className="font-mono text-xs h-8" onChange={(e) => updateTagValue(row.id, e.target.value)}
onChange={(e) => updateTagName(idx, e.target.value)}
/> />
)} )}
</div>
{/* Value: textarea for bio, plain input for everything else */}
{name === 'about' ? (
<Textarea
className="flex-1 text-sm min-h-[5rem] resize-y"
value={value}
onChange={(e) => updateTagValue(idx, e.target.value)}
/>
) : (
<Input
className="flex-1 font-mono text-sm"
value={value}
onChange={(e) => updateTagValue(idx, e.target.value)}
/>
)}
{/* Delete (singletons are permanent) */} {!isSingleton && (
{!isSingleton && ( <Button
<Button type="button"
type="button" variant="ghost"
variant="ghost" size="icon"
size="icon" className="shrink-0 text-muted-foreground hover:text-destructive mt-0.5"
className="shrink-0 text-muted-foreground hover:text-destructive mt-0.5" onClick={() => removeTag(row.id)}
onClick={() => removeTag(idx)} aria-label={t('Remove')}
aria-label={t('Remove')} >
> <Trash2 className="h-4 w-4" />
<Trash2 className="h-4 w-4" /> </Button>
</Button> )}
)} </div>
</div> </SortableEditorRow>
) )
})} })}
</EditorSortableList>
{/* Add-tag row: dropdown + single + button */} {/* Add-tag row: dropdown + single + button */}
<div className="flex flex-wrap gap-2 pt-1 items-center min-w-0"> <div className="flex flex-wrap gap-2 pt-1 items-center min-w-0">
@ -703,7 +724,6 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div>
</Item> </Item>
<div className="pb-2"> <div className="pb-2">
@ -821,33 +841,42 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('paytoEditor.intro', { {t('paytoEditor.intro', {
defaultValue: defaultValue:
'Choose a payment type, then enter the address or username shown in the hint below each field.' 'Choose a payment type, then enter the address or username shown in the hint below each field. Drag rows to reorder payto tags on save.'
})} })}
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
{paymentInfoEditMethods.map((row, idx) => ( <EditorSortableList
<PaymentMethodRow itemIds={paymentInfoEditMethods.map((row) => row.id)}
key={idx} onReorder={(from, to) => {
row={row} setPaymentInfoEditMethods((prev) => arrayMove(prev, from, to))
onChange={(next) => { }}
const methods = [...paymentInfoEditMethods] className="space-y-3"
methods[idx] = next >
setPaymentInfoEditMethods(methods) {paymentInfoEditMethods.map((row) => (
}} <SortableEditorRow key={row.id} id={row.id}>
onRemove={() => <PaymentMethodRow
setPaymentInfoEditMethods(paymentInfoEditMethods.filter((_, i) => i !== idx)) row={row}
} onChange={(next) => {
/> setPaymentInfoEditMethods((prev) =>
prev.map((m) => (m.id === row.id ? { ...m, ...next } : m))
)
}}
onRemove={() =>
setPaymentInfoEditMethods((prev) => prev.filter((m) => m.id !== row.id))
}
/>
</SortableEditorRow>
))} ))}
</EditorSortableList>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="gap-1" className="gap-1"
onClick={() => onClick={() =>
setPaymentInfoEditMethods([ setPaymentInfoEditMethods((prev) => [
...paymentInfoEditMethods, ...prev,
{ type: 'lightning', authority: '' } { id: newEditorId(), type: 'lightning', authority: '' }
]) ])
} }
> >
@ -922,17 +951,24 @@ export default ProfileEditorPage
// ─── Pure helpers (no React) ────────────────────────────────────────────────── // ─── Pure helpers (no React) ──────────────────────────────────────────────────
type EditorTagRow = { id: string; tag: string[] }
type EditorPaymentMethodRow = { id: string; type: string; authority: string }
function newEditorId(): string {
return crypto.randomUUID()
}
function tagRowsFromTags(tags: string[][]): EditorTagRow[] {
return tags.map((tag) => ({ id: newEditorId(), tag }))
}
/** /**
* Build the unified tag list from a stored profile event. * Build the unified tag list from a stored profile event.
* *
* Merge strategy: * Merge strategy:
* 1. Event `tags` (non-auto) in display order, then unknown tags alphabetically. * 1. Event `tags` (non-auto) in event order (top to bottom).
* 2. Content JSON fields not already covered: * 2. Content JSON fields not already covered (DISPLAY_ORDER for known keys).
* - Singletons: added only if absent from tags (tags take precedence).
* - Multi-fields: added when the exact (name, value) pair is not yet present.
* 3. Empty placeholder rows for any singleton still missing after steps 12. * 3. Empty placeholder rows for any singleton still missing after steps 12.
* 4. Final sort: known fields by DISPLAY_ORDER, unknown fields alphabetically.
* (Stable sort preserves relative order of same-name entries like multiple nip05.)
*/ */
function buildTagListFromEvent(event: Event | null): string[][] { function buildTagListFromEvent(event: Event | null): string[][] {
let content: Record<string, unknown> = {} let content: Record<string, unknown> = {}
@ -943,30 +979,16 @@ function buildTagListFromEvent(event: Event | null): string[][] {
const normalizeTagName = (n: string) => const normalizeTagName = (n: string) =>
n === 'displayName' ? 'display_name' : n === 'username' ? 'name' : n n === 'displayName' ? 'display_name' : n === 'username' ? 'name' : n
const eventTags = (event?.tags ?? [])
.filter((t) => Array.isArray(t) && !AUTO_NAMES.has((t as string[])[0]))
.map((t) => {
const norm = normalizeTagName((t as string[])[0])
return norm !== (t as string[])[0] ? [norm, ...(t as string[]).slice(1)] : [...(t as string[])]
}) as string[][]
// Group event tags by name for fast singleton-check.
const byName = new Map<string, string[][]>()
for (const tag of eventTags) {
const name = tag[0]
if (!byName.has(name)) byName.set(name, [])
byName.get(name)!.push([...tag])
}
const result: string[][] = [] const result: string[][] = []
const dedup = new Set<string>() // "name\0value" for multi-fields; just "name" for singletons const dedup = new Set<string>()
const singletonNamesFromTags = new Set<string>()
const push = (tag: string[]) => { const push = (tag: string[]) => {
const name = tag[0] const name = tag[0]
// Singletons: only one row per name ever.
if (SINGLETON_NAMES.includes(name)) { if (SINGLETON_NAMES.includes(name)) {
if (dedup.has(name)) return if (dedup.has(name)) return
dedup.add(name) dedup.add(name)
singletonNamesFromTags.add(name)
} else { } else {
const key = `${name}\0${tag[1] ?? ''}` const key = `${name}\0${tag[1] ?? ''}`
if (dedup.has(key)) return if (dedup.has(key)) return
@ -975,40 +997,33 @@ function buildTagListFromEvent(event: Event | null): string[][] {
result.push([...tag]) result.push([...tag])
} }
// 1a. Known tags in display order. for (const rawTag of event?.tags ?? []) {
for (const name of DISPLAY_ORDER) { if (!Array.isArray(rawTag) || AUTO_NAMES.has(rawTag[0])) continue
for (const tag of byName.get(name) ?? []) push(tag) const norm = normalizeTagName(rawTag[0])
const tag =
norm !== rawTag[0] ? [norm, ...rawTag.slice(1)] : ([...rawTag] as string[])
push(tag)
} }
// 1b. Unknown non-auto tags.
for (const tag of eventTags) { for (const name of DISPLAY_ORDER) {
if (!DISPLAY_ORDER.includes(tag[0])) push(tag) if (AUTO_NAMES.has(name) || name === 'bot') continue
const rawVal = content[name]
if (typeof rawVal !== 'string' || !rawVal.trim()) continue
if (SINGLETON_NAMES.includes(name) && singletonNamesFromTags.has(name)) continue
push([name, rawVal.trim()])
} }
// 2. Merge content JSON fields.
for (const [rawKey, val] of Object.entries(content)) { for (const [rawKey, val] of Object.entries(content)) {
if (typeof val !== 'string' || !val.trim()) continue if (typeof val !== 'string' || !val.trim()) continue
const name = normalizeTagName(rawKey) const name = normalizeTagName(rawKey)
if (AUTO_NAMES.has(name)) continue if (AUTO_NAMES.has(name) || DISPLAY_ORDER.includes(name)) continue
// For singletons: event tags take precedence; skip if already present.
if (SINGLETON_NAMES.includes(name) && byName.has(name)) continue
push([name, val.trim()]) push([name, val.trim()])
} }
// 3. Ensure all singletons have at least an empty placeholder.
for (const name of SINGLETON_NAMES) { for (const name of SINGLETON_NAMES) {
if (!result.some((t) => t[0] === name)) push([name, '']) if (!result.some((t) => t[0] === name)) push([name, ''])
} }
// 4. Sort: known fields by DISPLAY_ORDER index, unknown alphabetically.
result.sort((a, b) => {
const ai = DISPLAY_ORDER.indexOf(a[0])
const bi = DISPLAY_ORDER.indexOf(b[0])
if (ai !== -1 && bi !== -1) return ai - bi
if (ai !== -1) return -1
if (bi !== -1) return 1
return a[0].localeCompare(b[0])
})
return result return result
} }

Loading…
Cancel
Save