diff --git a/src/components/EditorSortableList/index.tsx b/src/components/EditorSortableList/index.tsx
new file mode 100644
index 00000000..a0b50d32
--- /dev/null
+++ b/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 (
+
+
+ {children}
+
+
+ )
+}
+
+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 (
+
+ )
+}
diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx
index 366ac4cb..000c518d 100644
--- a/src/pages/secondary/ProfileEditorPage/index.tsx
+++ b/src/pages/secondary/ProfileEditorPage/index.tsx
@@ -34,7 +34,9 @@ import {
} from '@/components/ui/select'
import { canUseNostrBuildThumb, toNostrBuildThumbUrl } from '@/lib/nostr-build'
import { isVideo } from '@/lib/url'
+import { EditorSortableList, SortableEditorRow } from '@/components/EditorSortableList'
import PaymentMethodRow from '@/components/ProfileEditor/PaymentMethodRow'
+import { arrayMove } from '@dnd-kit/sortable'
import { PAYTO_EDITOR_OTHER_OPTION } from '@/lib/payto'
import { normalizePaypalAuthority } from '@/lib/payto-paypal-url'
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(null)
const [paymentInfoEditOpen, setPaymentInfoEditOpen] = useState(false)
const [paymentInfoEditContent, setPaymentInfoEditContent] = useState('')
- const [paymentInfoEditMethods, setPaymentInfoEditMethods] = useState>([])
+ const [paymentInfoEditMethods, setPaymentInfoEditMethods] = useState([])
const [paymentInfoShowFullJson, setPaymentInfoShowFullJson] = useState(false)
const [savingPaymentInfo, setSavingPaymentInfo] = useState(false)
const savingPaymentInfoRef = useRef(false)
@@ -142,7 +144,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const [savingFullProfile, setSavingFullProfile] = useState(false)
const [refreshingCache, setRefreshingCache] = useState(false)
/** Single source of truth: all profile tags (excluding auto-managed client/alt). */
- const [profileTags, setProfileTags] = useState([])
+ const [profileTagRows, setProfileTagRows] = useState([])
const [imageUrlField, setImageUrlField] = useState<'picture' | 'banner' | null>(null)
const [imageUrlDraft, setImageUrlDraft] = useState('')
const [tagToAdd, setTagToAdd] = useState(ADD_TAG_OPTIONS[0])
@@ -152,9 +154,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
[account]
)
- /** Derived from profileTags so uploaders and visual preview stay in sync. */
- const avatar = profileTags.find((t) => t[0] === 'picture')?.[1] ?? ''
- const banner = profileTags.find((t) => t[0] === 'banner')?.[1] ?? ''
+ /** Derived from profile tag rows so uploaders and visual preview stay in sync. */
+ const avatar = profileTagRows.find((r) => r.tag[0] === 'picture')?.tag[1] ?? ''
+ const banner = profileTagRows.find((r) => r.tag[0] === 'banner')?.tag[1] ?? ''
useEffect(() => {
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.
useEffect(() => {
if (profileFormSyncLocked) return
- setProfileTags(buildTagListFromEvent(profileEvent ?? null))
+ setProfileTagRows(tagRowsFromTags(buildTagListFromEvent(profileEvent ?? null)))
}, [profileEvent, profileFormSyncLocked])
// Sync full-event JSON editor (same guard as tag list).
@@ -191,36 +193,47 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
// ─── Tag list helpers ────────────────────────────────────────────────────────
- const updateTagValue = (idx: number, value: string) => {
- setProfileTags((prev) => prev.map((t, i) => (i === idx ? [t[0], value, ...t.slice(2)] : t)))
+ const updateTagValue = (id: string, value: string) => {
+ setProfileTagRows((prev) =>
+ prev.map((row) =>
+ row.id === id ? { ...row, tag: [row.tag[0], value, ...row.tag.slice(2)] } : row
+ )
+ )
setHasChanged(true)
}
- const updateTagName = (idx: number, name: string) => {
- // Prevent renaming to a name that may only appear once and is already occupied.
+ const updateTagName = (id: string, name: string) => {
if (AT_MOST_ONE_NAMES.includes(name)) {
- const existingIdx = profileTags.findIndex((t) => t[0] === name)
- if (existingIdx !== -1 && existingIdx !== idx) {
+ const existing = profileTagRows.find((row) => row.tag[0] === name)
+ if (existing && existing.id !== id) {
toast.error(t('profileEditorDuplicateSingleton', { defaultValue: `"${name}" may only appear once` }))
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)
}
- const removeTag = (idx: number) => {
- setProfileTags((prev) => prev.filter((_, i) => i !== idx))
+ const reorderProfileTagRows = (fromIndex: number, toIndex: number) => {
+ setProfileTagRows((prev) => arrayMove(prev, fromIndex, toIndex))
setHasChanged(true)
}
const addTag = (name = '', value = '') => {
- // Prevent adding a second row for any "at most one" tag.
- if (name && AT_MOST_ONE_NAMES.includes(name) && profileTags.some((t) => t[0] === name)) {
+ if (name && AT_MOST_ONE_NAMES.includes(name) && profileTagRows.some((row) => row.tag[0] === name)) {
toast.error(t('profileEditorDuplicateSingleton', { defaultValue: `"${name}" may only appear once` }))
return
}
- setProfileTags((prev) => [...prev, [name, value]])
+ setProfileTagRows((prev) => [...prev, { id: newEditorId(), tag: [name, value] }])
setHasChanged(true)
}
@@ -239,14 +252,15 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
setPaymentInfoEditMethods(
paytoTags.length > 0
? paytoTags.map((tag) => ({
+ id: newEditorId(),
type: (tag[1] as string) || 'lightning',
authority: (tag[2] as string) || ''
}))
- : [{ type: 'lightning', authority: '' }]
+ : [{ id: newEditorId(), type: 'lightning', authority: '' }]
)
} else {
setPaymentInfoEditContent('{}')
- setPaymentInfoEditMethods([{ type: 'lightning', authority: '' }])
+ setPaymentInfoEditMethods([{ id: newEditorId(), type: 'lightning', authority: '' }])
}
setPaymentInfoShowFullJson(false)
setPaymentInfoEditOpen(true)
@@ -302,7 +316,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
])
if (profileEvt) {
await updateProfileEvent(profileEvt)
- setProfileTags(buildTagListFromEvent(profileEvt))
+ setProfileTagRows(tagRowsFromTags(buildTagListFromEvent(profileEvt)))
setProfileEventJson(JSON.stringify(profileEvt, null, 2))
setHasChanged(false)
}
@@ -361,38 +375,44 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const openImageUrlEditor = (field: 'picture' | 'banner') => {
setImageUrlField(field)
- setImageUrlDraft(profileTags.find((t) => t[0] === field)?.[1] ?? '')
+ setImageUrlDraft(profileTagRows.find((r) => r.tag[0] === field)?.tag[1] ?? '')
}
const applyImageUrlDraft = () => {
if (!imageUrlField) return
const v = imageUrlDraft.trim()
- setProfileTags((prev) => {
- const idx = prev.findIndex((t) => t[0] === imageUrlField)
+ setProfileTagRows((prev) => {
+ const idx = prev.findIndex((row) => row.tag[0] === imageUrlField)
return idx >= 0
- ? prev.map((t, i) => (i === idx ? [imageUrlField, v, ...t.slice(2)] : t))
- : [...prev, [imageUrlField, v]]
+ ? prev.map((row, i) =>
+ i === idx ? { ...row, tag: [imageUrlField, v, ...row.tag.slice(2)] } : row
+ )
+ : [...prev, { id: newEditorId(), tag: [imageUrlField, v] }]
})
setHasChanged(true)
setImageUrlField(null)
}
const onBannerUploadSuccess = ({ url }: { url: string }) => {
- setProfileTags((prev) => {
- const idx = prev.findIndex((t) => t[0] === 'banner')
+ setProfileTagRows((prev) => {
+ const idx = prev.findIndex((row) => row.tag[0] === 'banner')
return idx >= 0
- ? prev.map((t, i) => (i === idx ? ['banner', url, ...t.slice(2)] : t))
- : [...prev, ['banner', url]]
+ ? prev.map((row, i) =>
+ i === idx ? { ...row, tag: ['banner', url, ...row.tag.slice(2)] } : row
+ )
+ : [...prev, { id: newEditorId(), tag: ['banner', url] }]
})
setHasChanged(true)
}
const onAvatarUploadSuccess = ({ url }: { url: string }) => {
- setProfileTags((prev) => {
- const idx = prev.findIndex((t) => t[0] === 'picture')
+ setProfileTagRows((prev) => {
+ const idx = prev.findIndex((row) => row.tag[0] === 'picture')
return idx >= 0
- ? prev.map((t, i) => (i === idx ? ['picture', url, ...t.slice(2)] : t))
- : [...prev, ['picture', url]]
+ ? prev.map((row, i) =>
+ i === idx ? { ...row, tag: ['picture', url, ...row.tag.slice(2)] } : row
+ )
+ : [...prev, { id: newEditorId(), tag: ['picture', url] }]
})
setHasChanged(true)
}
@@ -407,7 +427,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const savePromise = (async () => {
try {
// Strip empty/incomplete rows, trim whitespace.
- const validTags = profileTags
+ const validTags = profileTagRows
+ .map((row) => row.tag)
.filter((t) => {
if (!Array.isArray(t) || !(t[0] ?? '').trim()) return false
const name = (t[0] ?? '').trim()
@@ -427,9 +448,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
return [name, v1, ...t.slice(2)]
})
- // Sort alphabetically by tag name (stable: same-name tags keep their relative order).
- const sortedTags = [...validTags]
- .sort((a, b) => a[0].localeCompare(b[0]))
+ const orderedTags = validTags
// Enforce at-most-one uniqueness: keep only the first occurrence.
.filter((() => {
const seen = new Set()
@@ -441,10 +460,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
}
})())
- // Derive content JSON: first occurrence of each known field.
const content: Record = {}
const seenContent = new Set()
- for (const tag of sortedTags) {
+ for (const tag of orderedTags) {
const name = tag[0]
if (name === 'bot') continue
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.
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)
await updateProfileEvent(published)
if (!mountedRef.current) return
@@ -587,11 +605,16 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
{t('profileEditorTagListHint', {
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.'
})}
-
- {profileTags.map((tag, idx) => {
+
row.id)}
+ onReorder={reorderProfileTagRows}
+ className="space-y-1.5"
+ >
+ {profileTagRows.map((row) => {
+ const tag = row.tag
const name = tag[0] ?? ''
const value = tag[1] ?? ''
const isSingleton = SINGLETON_NAMES.includes(name)
@@ -601,78 +624,76 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
if (isPic || isBan) {
return (
- openImageUrlEditor(name as 'picture' | 'banner')}
- onInsertThumb={() => {
- const next = insertNostrBuildThumbUrl(value)
- if (next) {
- setProfileTags((prev) =>
- prev.map((t, i) => (i === idx ? [name, next, ...t.slice(2)] : t))
- )
- setHasChanged(true)
- }
- }}
- showThumbButton={isPic && canInsertNostrBuildThumb(value)}
- t={t}
- />
+
+ openImageUrlEditor(name as 'picture' | 'banner')}
+ onInsertThumb={() => {
+ const next = insertNostrBuildThumbUrl(value)
+ if (next) {
+ updateTagValue(row.id, next)
+ }
+ }}
+ showThumbButton={isPic && canInsertNostrBuildThumb(value)}
+ t={t}
+ />
+
)
}
return (
-
- {/* Tag name: fixed label for known, editable input for custom */}
-
- {isKnown ? (
-
- {TAG_LABELS[name] || name}
-
+
+
+
+ {isKnown ? (
+
+ {TAG_LABELS[name] || name}
+
+ ) : (
+
updateTagName(row.id, e.target.value)}
+ />
+ )}
+
+
+ {name === 'about' ? (
+
-
- {/* Value: textarea for bio, plain input for everything else */}
- {name === 'about' ? (
-
+ {!isSingleton && (
+
removeTag(row.id)}
+ aria-label={t('Remove')}
+ >
+
+
+ )}
+
+
)
})}
+
{/* Add-tag row: dropdown + single + button */}
@@ -703,7 +724,6 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
-
@@ -821,33 +841,42 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
{t('paytoEditor.intro', {
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.'
})}
- {paymentInfoEditMethods.map((row, idx) => (
-
{
- const methods = [...paymentInfoEditMethods]
- methods[idx] = next
- setPaymentInfoEditMethods(methods)
- }}
- onRemove={() =>
- setPaymentInfoEditMethods(paymentInfoEditMethods.filter((_, i) => i !== idx))
- }
- />
+ row.id)}
+ onReorder={(from, to) => {
+ setPaymentInfoEditMethods((prev) => arrayMove(prev, from, to))
+ }}
+ className="space-y-3"
+ >
+ {paymentInfoEditMethods.map((row) => (
+
+ {
+ setPaymentInfoEditMethods((prev) =>
+ prev.map((m) => (m.id === row.id ? { ...m, ...next } : m))
+ )
+ }}
+ onRemove={() =>
+ setPaymentInfoEditMethods((prev) => prev.filter((m) => m.id !== row.id))
+ }
+ />
+
))}
+
- setPaymentInfoEditMethods([
- ...paymentInfoEditMethods,
- { type: 'lightning', authority: '' }
+ setPaymentInfoEditMethods((prev) => [
+ ...prev,
+ { id: newEditorId(), type: 'lightning', authority: '' }
])
}
>
@@ -922,17 +951,24 @@ export default ProfileEditorPage
// ─── 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.
*
* Merge strategy:
- * 1. Event `tags` (non-auto) in display order, then unknown tags alphabetically.
- * 2. Content JSON fields not already covered:
- * - Singletons: added only if absent from tags (tags take precedence).
- * - Multi-fields: added when the exact (name, value) pair is not yet present.
+ * 1. Event `tags` (non-auto) in event order (top to bottom).
+ * 2. Content JSON fields not already covered (DISPLAY_ORDER for known keys).
* 3. Empty placeholder rows for any singleton still missing after steps 1–2.
- * 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[][] {
let content: Record = {}
@@ -943,30 +979,16 @@ function buildTagListFromEvent(event: Event | null): string[][] {
const normalizeTagName = (n: string) =>
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()
- 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 dedup = new Set() // "name\0value" for multi-fields; just "name" for singletons
+ const dedup = new Set()
+ const singletonNamesFromTags = new Set()
const push = (tag: string[]) => {
const name = tag[0]
- // Singletons: only one row per name ever.
if (SINGLETON_NAMES.includes(name)) {
if (dedup.has(name)) return
dedup.add(name)
+ singletonNamesFromTags.add(name)
} else {
const key = `${name}\0${tag[1] ?? ''}`
if (dedup.has(key)) return
@@ -975,40 +997,33 @@ function buildTagListFromEvent(event: Event | null): string[][] {
result.push([...tag])
}
- // 1a. Known tags in display order.
- for (const name of DISPLAY_ORDER) {
- for (const tag of byName.get(name) ?? []) push(tag)
+ for (const rawTag of event?.tags ?? []) {
+ if (!Array.isArray(rawTag) || AUTO_NAMES.has(rawTag[0])) continue
+ 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) {
- if (!DISPLAY_ORDER.includes(tag[0])) push(tag)
+
+ for (const name of DISPLAY_ORDER) {
+ 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)) {
if (typeof val !== 'string' || !val.trim()) continue
const name = normalizeTagName(rawKey)
- if (AUTO_NAMES.has(name)) continue
- // For singletons: event tags take precedence; skip if already present.
- if (SINGLETON_NAMES.includes(name) && byName.has(name)) continue
+ if (AUTO_NAMES.has(name) || DISPLAY_ORDER.includes(name)) continue
push([name, val.trim()])
}
- // 3. Ensure all singletons have at least an empty placeholder.
for (const name of SINGLETON_NAMES) {
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
}