From a570b318d72b5d9fef9a16fd728c19dd54ce8d08 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Wed, 1 Apr 2026 02:16:14 -0500 Subject: [PATCH] Revamp My Badges tab: draggable badge order, scroll areas, and pending carousel - Extract reusable SortableList/SortableItem components sharing the same @dnd-kit pattern used by the sidebar edit view (DRY) - Replace ChevronUp/ChevronDown reorder buttons with drag-and-drop on accepted badges list - Wrap accepted and created badge sections in ScrollArea (max-h 420px) - Redesign pending badges as a carousel showing one badge at a time in the notification-style BadgeContent presentation (rotating rays, 3D tilt), with left/right arrows to navigate the pending queue --- src/components/SortableList.tsx | 109 ++++++++++ src/pages/BadgesPage.tsx | 341 +++++++++++++++----------------- 2 files changed, 266 insertions(+), 184 deletions(-) create mode 100644 src/components/SortableList.tsx diff --git a/src/components/SortableList.tsx b/src/components/SortableList.tsx new file mode 100644 index 00000000..4956c200 --- /dev/null +++ b/src/components/SortableList.tsx @@ -0,0 +1,109 @@ +import { useCallback } from 'react'; +import { GripVertical } from 'lucide-react'; +import { + DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + SortableContext, verticalListSortingStrategy, useSortable, arrayMove, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { cn } from '@/lib/utils'; + +// ── Generic sortable list container ────────────────────────────────────────── + +export interface SortableListProps { + /** Items in current order. */ + items: T[]; + /** Extract a unique stable string id from each item. */ + getItemId: (item: T, index: number) => string; + /** Called with the reordered items array after a drag completes. */ + onReorder: (items: T[]) => void; + /** Render each item. The wrapper provides the sortable ref, transform, and drag handle. */ + renderItem: (item: T, index: number) => React.ReactNode; + /** Additional classes on the outer container. */ + className?: string; +} + +/** + * Generic drag-and-drop sortable list. + * + * Reuses the same DnD-kit sensor configuration and vertical sort strategy + * used by the sidebar edit view. Wrap each child in `` to + * get the grip handle and drag styling for free. + */ +export function SortableList({ items, getItemId, onReorder, renderItem, className }: SortableListProps) { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor), + ); + + const sortableIds = items.map(getItemId); + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = sortableIds.indexOf(active.id as string); + const newIndex = sortableIds.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1) return; + onReorder(arrayMove(items, oldIndex, newIndex)); + }, [sortableIds, items, onReorder]); + + return ( + + +
+ {items.map((item, i) => renderItem(item, i))} +
+
+
+ ); +} + +// ── Generic sortable item wrapper ──────────────────────────────────────────── + +export interface SortableItemProps { + /** Must match the id returned by `getItemId` for this item. */ + id: string; + /** When false the grip handle is hidden and dragging is disabled. */ + enabled?: boolean; + /** Additional classes on the wrapper div. */ + className?: string; + /** Classes applied while the item is being dragged. */ + draggingClassName?: string; + children: React.ReactNode; +} + +/** + * Wraps a single child with `useSortable` and renders a grip-vertical + * drag handle. Shares the same visual pattern as the sidebar edit view. + */ +export function SortableItem({ id, enabled = true, className, draggingClassName, children }: SortableItemProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !enabled }); + const style = { transform: CSS.Transform.toString(transform), transition }; + + return ( +
+ {enabled && ( + + )} +
+ {children} +
+
+ ); +} diff --git a/src/pages/BadgesPage.tsx b/src/pages/BadgesPage.tsx index 6f4b4865..e053a4f0 100644 --- a/src/pages/BadgesPage.tsx +++ b/src/pages/BadgesPage.tsx @@ -5,8 +5,8 @@ import { useSeoMeta } from "@unhead/react"; import { Award, Check, - ChevronDown, - ChevronUp, + ChevronLeft, + ChevronRight, Clock, ExternalLink, Loader2, @@ -23,6 +23,7 @@ import { Link } from "react-router-dom"; import { AwardBadgeDialog } from "@/components/AwardBadgeDialog"; import { LoginArea } from "@/components/auth/LoginArea"; import { + BadgeContent, type BadgeData, parseBadgeDefinition, } from "@/components/BadgeContent"; @@ -33,6 +34,7 @@ import { FeedEmptyState } from "@/components/FeedEmptyState"; import { NoteCard } from "@/components/NoteCard"; import { PageHeader } from "@/components/PageHeader"; import { PullToRefresh } from "@/components/PullToRefresh"; +import { SortableList, SortableItem } from "@/components/SortableList"; import { SubHeaderBar } from "@/components/SubHeaderBar"; import { TabButton } from "@/components/TabButton"; import { @@ -51,6 +53,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; import { Textarea } from "@/components/ui/textarea"; import { useLayoutOptions } from "@/contexts/LayoutContext"; @@ -75,7 +78,6 @@ import { useUploadFile } from "@/hooks/useUploadFile"; import { BADGE_AWARD_KIND, BADGE_DEFINITION_KIND, getBadgeATag } from "@/lib/badgeUtils"; import { deduplicateEvents } from "@/lib/deduplicateEvents"; import { genUserName } from "@/lib/genUserName"; -import { timeAgo } from "@/lib/timeAgo"; // ─── Types ───────────────────────────────────────────────────────────────────── @@ -446,7 +448,7 @@ function SectionHeader({ ); } -// ─── Pending Badge List ──────────────────────────────────────────────────────── +// ─── Pending Badge Carousel ──────────────────────────────────────────────────── function PendingBadgeList({ pendingBadges, @@ -456,45 +458,35 @@ function PendingBadgeList({ badgeMap: Map; }) { const [dismissedATags, setDismissedATags] = useState>(new Set()); + const [currentIndex, setCurrentIndex] = useState(0); + const { toast } = useToast(); + const { mutate: acceptBadge, isPending: isAccepting } = useAcceptBadge(); + const visibleBadges = pendingBadges.filter( (p) => !dismissedATags.has(p.aTag), ); + // Clamp index when badges are dismissed + useEffect(() => { + if (currentIndex >= visibleBadges.length && visibleBadges.length > 0) { + setCurrentIndex(visibleBadges.length - 1); + } + }, [currentIndex, visibleBadges.length]); + const handleDismiss = useCallback((aTag: string) => { setDismissedATags((prev) => new Set(prev).add(aTag)); }, []); if (visibleBadges.length === 0) return null; - return ( -
- {visibleBadges.map((pending) => ( - handleDismiss(pending.aTag)} - /> - ))} -
- ); -} - -function PendingBadgeRow({ - pending, - badge, - onDismiss, -}: { - pending: PendingBadge; - badge: BadgeDefinition | undefined; - onDismiss: () => void; -}) { - const { toast } = useToast(); - const { mutate: acceptBadge, isPending: isAccepting } = useAcceptBadge(); + const current = visibleBadges[currentIndex]; + const badge = badgeMap.get(current.aTag); + const hasPrev = currentIndex > 0; + const hasNext = currentIndex < visibleBadges.length - 1; const handleAccept = () => { acceptBadge( - { aTag: pending.aTag, awardEventId: pending.awardEvent.id }, + { aTag: current.aTag, awardEventId: current.awardEvent.id }, { onSuccess: () => toast({ title: "Badge accepted!" }), onError: () => @@ -503,67 +495,107 @@ function PendingBadgeRow({ ); }; + const badgeNaddr = nip19.naddrEncode({ + kind: BADGE_DEFINITION_KIND, + pubkey: current.issuerPubkey, + identifier: current.identifier, + }); + return ( -
- {badge ? ( - - ) : ( - - )} -
-
- - {badge?.name ?? pending.identifier} - -
-
- - · - - {timeAgo(pending.awardedAt)} - -
+
+ {/* Badge showcase -- notification-style presentation */} + + {badge?.event ? ( + + ) : ( +
+ +
+ + +
+
+ )} + + + {/* Issuer line */} +
+ from +
-
+ + {/* Actions */} +
+ + {/* Navigation bar */} + {visibleBadges.length > 1 && ( +
+ + + {currentIndex + 1} of {visibleBadges.length} + + +
+ )}
); } function PendingBadgeSkeleton() { return ( -
- -
- - +
+
+ +
+ + +
-
- - +
+ +
); @@ -592,14 +624,11 @@ function AcceptedBadgeList({ const { mutate: reorderBadges, isPending: isReordering } = useReorderBadges(); const { mutate: removeBadge } = useRemoveBadge(); - const moveUp = useCallback( - (index: number) => { - if (index <= 0) return; - const next = [...refs]; - [next[index - 1], next[index]] = [next[index], next[index - 1]]; - setRefs(next); + const handleReorder = useCallback( + (newRefs: AcceptedRef[]) => { + setRefs(newRefs); reorderBadges( - next.map((r) => ({ aTag: r.aTag, eTag: r.eTag })), + newRefs.map((r) => ({ aTag: r.aTag, eTag: r.eTag })), { onError: () => toast({ @@ -609,27 +638,7 @@ function AcceptedBadgeList({ }, ); }, - [refs, setRefs, reorderBadges, toast], - ); - - const moveDown = useCallback( - (index: number) => { - if (index >= refs.length - 1) return; - const next = [...refs]; - [next[index], next[index + 1]] = [next[index + 1], next[index]]; - setRefs(next); - reorderBadges( - next.map((r) => ({ aTag: r.aTag, eTag: r.eTag })), - { - onError: () => - toast({ - title: "Failed to reorder badges", - variant: "destructive", - }), - }, - ); - }, - [refs, setRefs, reorderBadges, toast], + [setRefs, reorderBadges, toast], ); const handleRemove = useCallback( @@ -644,92 +653,54 @@ function AcceptedBadgeList({ ); return ( -
+
{isReordering && (
)} - {refs.map((ref, index) => ( - moveUp(index)} - onMoveDown={() => moveDown(index)} - onRemove={() => handleRemove(ref.aTag)} + + ref.aTag} + onReorder={handleReorder} + className="space-y-1.5" + renderItem={(ref, index) => ( + +
+ + {index + 1} + + {badgeMap.get(ref.aTag) ? ( + + ) : ( + + )} +
+ + {badgeMap.get(ref.aTag)?.name ?? ref.identifier} + + +
+ +
+
+ )} /> - ))} -
- ); -} - -function AcceptedBadgeRow({ - ref_, - index, - total, - badge, - onMoveUp, - onMoveDown, - onRemove, -}: { - ref_: AcceptedRef; - index: number; - total: number; - badge: BadgeDefinition | undefined; - onMoveUp: () => void; - onMoveDown: () => void; - onRemove: () => void; -}) { - return ( -
- - {index + 1} - - {badge ? ( - - ) : ( - - )} -
- - {badge?.name ?? ref_.identifier} - - -
-
- - -
- +
); } @@ -778,15 +749,17 @@ function CreatedBadgeList({ badges }: { badges: ParsedBadge[] }) { } return ( -
- {badges.map((badge) => ( - - ))} -
+ +
+ {badges.map((badge) => ( + + ))} +
+
); }