Skip to content

Commit efd18b5

Browse files
jeffdyerclaude
andcommitted
Add drag-to-reorder for items and images
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4ba5904 commit efd18b5

3 files changed

Lines changed: 140 additions & 14 deletions

File tree

src/components/ImageGallery.tsx

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect, useCallback } from 'react';
1+
import React, { useState, useEffect, useCallback, useRef } from 'react';
22
import { getStorage } from 'firebase/storage';
33
import { useFirebaseApp } from 'reactfire';
44
import useGraffiticodeAuth from '../hooks/use-graffiticode-auth';
@@ -21,6 +21,9 @@ export function ImageGallery() {
2121
const [dragging, setDragging] = useState(false);
2222
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
2323
const [uploadError, setUploadError] = useState<string | null>(null);
24+
const dragImageRef = useRef<string | null>(null);
25+
const dragOverImageRef = useRef<string | null>(null);
26+
const [dragOverUrl, setDragOverUrl] = useState<string | null>(null);
2427

2528
const loadImages = useCallback(async () => {
2629
if (!user?.uid) {
@@ -31,7 +34,21 @@ export function ImageGallery() {
3134
setLoading(true);
3235
setError(null);
3336
const storage = getStorage(firebaseApp);
34-
const result = await listUserImages(storage, user.uid);
37+
let result = await listUserImages(storage, user.uid);
38+
// Apply saved order from localStorage
39+
const orderKey = `graffiticode:imageOrder:${user.uid}`;
40+
const savedOrder = localStorage.getItem(orderKey);
41+
if (savedOrder) {
42+
try {
43+
const orderUrls: string[] = JSON.parse(savedOrder);
44+
const orderMap = new Map(orderUrls.map((url, idx) => [url, idx]));
45+
result = [...result].sort((a, b) => {
46+
const aIdx = orderMap.has(a.downloadURL) ? orderMap.get(a.downloadURL)! : Infinity;
47+
const bIdx = orderMap.has(b.downloadURL) ? orderMap.get(b.downloadURL)! : Infinity;
48+
return aIdx - bIdx;
49+
});
50+
} catch {}
51+
}
3552
setImages(result);
3653
} catch (err) {
3754
setError('Failed to load images');
@@ -173,21 +190,60 @@ export function ImageGallery() {
173190
const isSelected = selectedUrls.has(img.downloadURL);
174191
// When dragging, use all selected if this image is selected, otherwise just this image
175192
const dragUrls = isSelected && selectedUrls.size > 0 ? selectedUrls : new Set([img.downloadURL]);
193+
const isDragOver = dragOverUrl === img.downloadURL && dragImageRef.current !== img.downloadURL;
176194
return (
177195
<button
178196
key={img.downloadURL}
179197
onClick={(e) => handleClick(img, e)}
180198
draggable
181199
onDragStart={(e) => {
200+
dragImageRef.current = img.downloadURL;
182201
const markdown = buildMarkdown(dragUrls);
183202
e.dataTransfer.setData('text/plain', markdown);
184203
e.dataTransfer.setData('application/x-gc-image', 'true');
185-
e.dataTransfer.effectAllowed = 'copy';
204+
e.dataTransfer.setData('application/x-gc-image-reorder', img.downloadURL);
205+
e.dataTransfer.effectAllowed = 'copyMove';
206+
}}
207+
onDragOver={(e) => {
208+
if (dragImageRef.current) {
209+
e.preventDefault();
210+
e.dataTransfer.dropEffect = 'move';
211+
if (dragOverImageRef.current !== img.downloadURL) {
212+
dragOverImageRef.current = img.downloadURL;
213+
setDragOverUrl(img.downloadURL);
214+
}
215+
}
216+
}}
217+
onDrop={(e) => {
218+
if (dragImageRef.current && dragImageRef.current !== img.downloadURL) {
219+
e.preventDefault();
220+
e.stopPropagation();
221+
const fromIdx = images.findIndex(i => i.downloadURL === dragImageRef.current);
222+
const toIdx = images.findIndex(i => i.downloadURL === img.downloadURL);
223+
if (fromIdx !== -1 && toIdx !== -1) {
224+
const reordered = [...images];
225+
const [moved] = reordered.splice(fromIdx, 1);
226+
reordered.splice(toIdx, 0, moved);
227+
setImages(reordered);
228+
const orderKey = `graffiticode:imageOrder:${user.uid}`;
229+
localStorage.setItem(orderKey, JSON.stringify(reordered.map(i => i.downloadURL)));
230+
}
231+
}
232+
dragImageRef.current = null;
233+
dragOverImageRef.current = null;
234+
setDragOverUrl(null);
235+
}}
236+
onDragEnd={() => {
237+
dragImageRef.current = null;
238+
dragOverImageRef.current = null;
239+
setDragOverUrl(null);
186240
}}
187241
className={`group relative flex flex-col items-center p-2 rounded cursor-pointer border ${
188-
isSelected
189-
? 'border-blue-500 bg-blue-50'
190-
: 'border-transparent hover:border-gray-200 hover:bg-gray-50'
242+
isDragOver
243+
? 'border-blue-400 bg-blue-50'
244+
: isSelected
245+
? 'border-blue-500 bg-blue-50'
246+
: 'border-transparent hover:border-gray-200 hover:bg-gray-50'
191247
}`}
192248
>
193249
<span

src/components/ItemsNav.tsx

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,51 @@ function EllipsisMenu({ itemId, name, taskId, mark, isPublic, sharedWith = [], l
268268
)
269269
}
270270

271-
export default function ItemsNav({ items, selectedItemId, onSelectItem, onUpdateItem, onRefresh, panelWidth = 210 }) {
271+
export default function ItemsNav({ items, selectedItemId, onSelectItem, onUpdateItem, onRefresh, onReorderItems, panelWidth = 210 }) {
272272
const [ showId, setShowId ] = useState("");
273+
const dragItemRef = useRef<string | null>(null);
274+
const dragOverItemRef = useRef<string | null>(null);
275+
const [dragOverId, setDragOverId] = useState<string | null>(null);
276+
277+
const handleDragStart = (e: React.DragEvent, itemId: string) => {
278+
dragItemRef.current = itemId;
279+
e.dataTransfer.effectAllowed = 'move';
280+
e.dataTransfer.setData('application/x-gc-item-reorder', itemId);
281+
};
282+
283+
const handleDragOver = (e: React.DragEvent, itemId: string) => {
284+
e.preventDefault();
285+
e.dataTransfer.dropEffect = 'move';
286+
if (dragOverItemRef.current !== itemId) {
287+
dragOverItemRef.current = itemId;
288+
setDragOverId(itemId);
289+
}
290+
};
291+
292+
const handleDrop = (e: React.DragEvent) => {
293+
e.preventDefault();
294+
const fromId = dragItemRef.current;
295+
const toId = dragOverItemRef.current;
296+
if (fromId && toId && fromId !== toId && onReorderItems) {
297+
const fromIndex = items.findIndex(i => i.id === fromId);
298+
const toIndex = items.findIndex(i => i.id === toId);
299+
if (fromIndex !== -1 && toIndex !== -1) {
300+
const reordered = [...items];
301+
const [moved] = reordered.splice(fromIndex, 1);
302+
reordered.splice(toIndex, 0, moved);
303+
onReorderItems(reordered);
304+
}
305+
}
306+
dragItemRef.current = null;
307+
dragOverItemRef.current = null;
308+
setDragOverId(null);
309+
};
310+
311+
const handleDragEnd = () => {
312+
dragItemRef.current = null;
313+
dragOverItemRef.current = null;
314+
setDragOverId(null);
315+
};
273316

274317
return (
275318
<div className="w-full flex flex-col gap-y-1 bg-gray-100 pt-1 pr-2">
@@ -279,7 +322,15 @@ export default function ItemsNav({ items, selectedItemId, onSelectItem, onUpdate
279322
) : (
280323
<ul role="list" className="space-y-1 font-mono">
281324
{items.map((item) => (
282-
<li key={item.id}>
325+
<li
326+
key={item.id}
327+
draggable
328+
onDragStart={(e) => handleDragStart(e, item.id)}
329+
onDragOver={(e) => handleDragOver(e, item.id)}
330+
onDrop={handleDrop}
331+
onDragEnd={handleDragEnd}
332+
className={dragOverId === item.id && dragItemRef.current !== item.id ? 'border-t-2 border-blue-400' : ''}
333+
>
283334
<div
284335
className={classNames(
285336
item.id === selectedItemId ? 'bg-gray-300' : 'bg-gray-100 hover:bg-gray-200',

src/components/gallery.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,21 @@ export default function Gallery({ lang, mark, hideItemsNav = false, itemId: init
266266
if (directItem && initialItemId) return;
267267

268268
if (loadedItems && loadedItems.length > 0) {
269-
setItems(loadedItems);
269+
// Apply saved item order from localStorage
270+
const savedOrder = typeof window !== 'undefined' ? localStorage.getItem('graffiticode:itemOrder') : null;
271+
let orderedItems = loadedItems;
272+
if (savedOrder) {
273+
try {
274+
const orderIds: string[] = JSON.parse(savedOrder);
275+
const orderMap = new Map(orderIds.map((id, idx) => [id, idx]));
276+
orderedItems = [...loadedItems].sort((a, b) => {
277+
const aIdx = orderMap.has(a.id) ? orderMap.get(a.id)! : Infinity;
278+
const bIdx = orderMap.has(b.id) ? orderMap.get(b.id)! : Infinity;
279+
return aIdx - bIdx;
280+
});
281+
} catch {}
282+
}
283+
setItems(orderedItems);
270284
// Priority: 1) initialItemId prop, 2) localStorage, 3) first item
271285
const targetItemId = initialItemId ||
272286
(typeof window !== 'undefined' ? localStorage.getItem(`graffiticode:selected:itemId`) : null);
@@ -281,11 +295,11 @@ export default function Gallery({ lang, mark, hideItemsNav = false, itemId: init
281295
}
282296
}
283297
// Default to the first item if no saved selection
284-
if (loadedItems[0]) {
285-
setSelectedItemId(loadedItems[0].id);
286-
setTaskId(loadedItems[0].taskId);
287-
setEditorHelp(typeof loadedItems[0].help === "string" ? JSON.parse(loadedItems[0].help || "[]") : (loadedItems[0].help || []));
288-
loadItemSource(loadedItems[0].id, loadedItems[0].taskId, loadedItems[0].code);
298+
if (orderedItems[0]) {
299+
setSelectedItemId(orderedItems[0].id);
300+
setTaskId(orderedItems[0].taskId);
301+
setEditorHelp(typeof orderedItems[0].help === "string" ? JSON.parse(orderedItems[0].help || "[]") : (orderedItems[0].help || []));
302+
loadItemSource(orderedItems[0].id, orderedItems[0].taskId, orderedItems[0].code);
289303
}
290304
} else if (!initialItemId) {
291305
setItems([]);
@@ -690,6 +704,11 @@ export default function Gallery({ lang, mark, hideItemsNav = false, itemId: init
690704
onSelectItem={handleSelectItem}
691705
onUpdateItem={handleUpdateItem}
692706
onRefresh={() => mutate()}
707+
onReorderItems={(reordered) => {
708+
setItems(reordered);
709+
const orderIds = reordered.map(i => i.id);
710+
localStorage.setItem('graffiticode:itemOrder', JSON.stringify(orderIds));
711+
}}
693712
panelWidth={itemsPanelWidth}
694713
/>
695714
)}

0 commit comments

Comments
 (0)