Uplofile is open sourceStar on GitHub

Sortable Image Gallery

An image gallery uploader that allows reordering completed items using drag and drop.

Preview

Code

1import {
2 UplofileRoot,
3 UplofileDropzone,
4 UplofileTrigger,
5 UplofilePreview,
6 type UploadFileItem,
7} from "@/components/ui/uplofile";
8import {
9 IoAddOutline,
10 IoImageOutline,
11 IoReloadOutline,
12 IoCheckmarkCircleOutline,
13 IoAlertCircleOutline,
14 IoReorderTwoOutline,
15} from "react-icons/io5";
16import { mockUpload } from "@/lib/utils.ts";
17import {
18 DndContext,
19 closestCenter,
20 KeyboardSensor,
21 PointerSensor,
22 useSensor,
23 useSensors,
24 type DragEndEvent,
25} from "@dnd-kit/core";
26import {
27 arrayMove,
28 SortableContext,
29 sortableKeyboardCoordinates,
30 rectSortingStrategy,
31 useSortable,
32} from "@dnd-kit/sortable";
33import { CSS } from "@dnd-kit/utilities";
34
35export default function SortableGalleryDemo() {
36 const sensors = useSensors(
37 useSensor(PointerSensor),
38 useSensor(KeyboardSensor, {
39 coordinateGetter: sortableKeyboardCoordinates,
40 }),
41 );
42
43 return (
44 <UplofileRoot upload={mockUpload} accept="image/*" multiple>
45 <UplofilePreview
46 render={({ items, setItems }) => {
47 function handleDragEnd(event: DragEndEvent) {
48 const { active, over } = event;
49
50 if (over && active.id !== over.id) {
51 const oldIndex = items.findIndex(
52 (item) => item.uid === active.id,
53 );
54 const newIndex = items.findIndex((item) => item.uid === over.id);
55
56 setItems(arrayMove(items, oldIndex, newIndex));
57 }
58 }
59
60 return (
61 <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
62 <DndContext
63 sensors={sensors}
64 collisionDetection={closestCenter}
65 onDragEnd={handleDragEnd}
66 >
67 <SortableContext
68 items={items.map((i) => i.uid)}
69 strategy={rectSortingStrategy}
70 >
71 {items.map((item) => (
72 <SortableImageItem key={item.uid} item={item} />
73 ))}
74 </SortableContext>
75 </DndContext>
76
77 <UplofileDropzone className="group aspect-square rounded-xl flex flex-col items-center justify-center border-2 border-dashed border-muted-foreground/25 hover:border-primary/50 hover:bg-primary/5 cursor-pointer transition-all duration-200 data-[dragging=true]:border-primary data-[dragging=true]:bg-primary/10 data-[dragging=true]:scale-95">
78 <UplofileTrigger>
79 <div className="flex flex-col items-center gap-2 text-muted-foreground group-hover:text-primary transition-colors">
80 <div className="p-3 rounded-full bg-muted group-hover:bg-primary/10 transition-colors">
81 <IoAddOutline className="h-6 w-6" />
82 </div>
83 <span className="text-[10px] font-bold uppercase tracking-wider">
84 Add Image
85 </span>
86 </div>
87 </UplofileTrigger>
88 </UplofileDropzone>
89 </div>
90 );
91 }}
92 />
93 </UplofileRoot>
94 );
95}
96
97function SortableImageItem({ item }: { item: UploadFileItem }) {
98 const isDraggable = item.status === "done";
99 const {
100 attributes,
101 listeners,
102 setNodeRef,
103 transform,
104 transition,
105 isDragging,
106 } = useSortable({
107 id: item.uid,
108 disabled: !isDraggable,
109 });
110
111 const style = {
112 transform: CSS.Transform.toString(transform),
113 transition,
114 zIndex: isDragging ? 10 : undefined,
115 };
116
117 return (
118 <div
119 ref={setNodeRef}
120 style={style}
121 className={`group relative aspect-square rounded-xl overflow-hidden bg-white border shadow-sm animate-in fade-in zoom-in-95 duration-200 ${isDragging ? "opacity-50" : ""}`}
122 >
123 {item.previewUrl ? (
124 <img
125 src={item.previewUrl}
126 alt={item.name}
127 className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
128 />
129 ) : (
130 <div className="w-full h-full flex items-center justify-center bg-muted/50">
131 <IoImageOutline className="h-6 w-6 text-muted-foreground/40" />
132 </div>
133 )}
134
135 {item.status === "uploading" && (
136 <div className="absolute inset-0 bg-black/60 backdrop-blur-[2px] flex flex-col items-center justify-center p-2">
137 <IoReloadOutline className="h-6 w-6 text-white animate-spin mb-2" />
138 <div className="w-full bg-white/20 rounded-full h-1 max-w-[40px]">
139 <div
140 className="bg-white h-full rounded-full transition-all duration-300"
141 style={{ width: `${item.progress}%` }}
142 />
143 </div>
144 </div>
145 )}
146
147 {item.status === "done" && (
148 <>
149 <div className="absolute top-2 right-2 bg-emerald-500 text-white p-1 rounded-full shadow-lg scale-0 group-hover:scale-100 transition-transform duration-200">
150 <IoCheckmarkCircleOutline className="h-3 w-3" />
151 </div>
152 {/* Drag Handle */}
153 <div
154 {...attributes}
155 {...listeners}
156 className="absolute top-2 left-2 bg-white/80 backdrop-blur-sm text-gray-600 p-1 rounded-md shadow-sm border opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing"
157 >
158 <IoReorderTwoOutline className="h-3 w-3" />
159 </div>
160 </>
161 )}
162
163 {item.status === "error" && (
164 <div className="absolute inset-0 bg-destructive/60 backdrop-blur-[1px] flex flex-col items-center justify-center p-2 text-white animate-in zoom-in-95">
165 <IoAlertCircleOutline className="h-6 w-6 mb-1" />
166 <span className="text-[10px] font-bold uppercase tracking-wider">
167 Failed
168 </span>
169 </div>
170 )}
171
172 <div className="absolute inset-0 bg-black/5 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
173 </div>
174 );
175}

Key Points

  • Integrated with @dnd-kit for smooth drag-and-drop
  • Only completed items (status === 'done') are draggable
  • Drag handle appears on hover in the top-left corner
  • Reordering is preserved through the setItems callback