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 ( +
+ +
{children}
+
+ ) +} 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' ? ( +