Skip to content

Commit 6a3b830

Browse files
committed
feat(links): add drag-and-drop sorting to link pills
- Add drag handlers (dragStart, dragOver, dragLeave, drop, dragEnd) - Prevent link clicks when dragging to avoid accidental navigation - Add visual feedback during drag (opacity, scale, border highlight) - Use ref-based drag tracking for reliable click prevention
1 parent ee28b90 commit 6a3b830

File tree

1 file changed

+85
-5
lines changed

1 file changed

+85
-5
lines changed

app/components/LinkInput.tsx

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export const LinkInput = ({ selectedLinks, onLinksChange, disabled = false }: Li
4848
const [isAdding, setIsAdding] = useState(false)
4949
const [inputValue, setInputValue] = useState("")
5050
const [error, setError] = useState<string | null>(null)
51+
const [draggedLinkId, setDraggedLinkId] = useState<number | null>(null)
52+
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
53+
const hasDraggedRef = useRef(false)
5154
const inputRef = useRef<HTMLInputElement>(null)
5255
const containerRef = useRef<HTMLDivElement>(null)
5356

@@ -124,15 +127,84 @@ export const LinkInput = ({ selectedLinks, onLinksChange, disabled = false }: Li
124127
setError(null)
125128
}
126129

127-
const handleLinkClick = (url: string) => {
130+
const handleDragStart = (e: React.DragEvent, linkId: number) => {
131+
if (disabled) return
132+
setDraggedLinkId(linkId)
133+
hasDraggedRef.current = false
134+
e.dataTransfer.effectAllowed = "move"
135+
e.dataTransfer.setData("text/html", "")
136+
}
137+
138+
const handleDragOver = (e: React.DragEvent, index: number) => {
139+
if (disabled || draggedLinkId === null) return
140+
e.preventDefault()
141+
e.dataTransfer.dropEffect = "move"
142+
setDragOverIndex(index)
143+
hasDraggedRef.current = true
144+
}
145+
146+
const handleDragLeave = () => {
147+
setDragOverIndex(null)
148+
}
149+
150+
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
151+
if (disabled || draggedLinkId === null) return
152+
e.preventDefault()
153+
154+
const draggedIndex = selectedLinks.findIndex((l) => l.id === draggedLinkId)
155+
if (draggedIndex === -1 || draggedIndex === dropIndex) {
156+
setDraggedLinkId(null)
157+
setDragOverIndex(null)
158+
hasDraggedRef.current = false
159+
return
160+
}
161+
162+
const newLinks = [...selectedLinks]
163+
const [draggedLink] = newLinks.splice(draggedIndex, 1)
164+
newLinks.splice(dropIndex, 0, draggedLink)
165+
166+
onLinksChange(newLinks)
167+
setDraggedLinkId(null)
168+
setDragOverIndex(null)
169+
hasDraggedRef.current = false
170+
}
171+
172+
const handleDragEnd = () => {
173+
setDraggedLinkId(null)
174+
setDragOverIndex(null)
175+
// Reset hasDragged after a short delay to allow click handler to check it
176+
setTimeout(() => {
177+
hasDraggedRef.current = false
178+
}, 0)
179+
}
180+
181+
const handleLinkClick = (url: string, e: React.MouseEvent) => {
182+
// Prevent click if we just dragged
183+
if (hasDraggedRef.current) {
184+
e.preventDefault()
185+
e.stopPropagation()
186+
return
187+
}
128188
window.open(url, "_blank", "noopener,noreferrer")
129189
}
130190

131191
return (
132192
<Container ref={containerRef}>
133193
<LinkCloud>
134-
{selectedLinks.map((link) => (
135-
<LinkPill key={link.id} onClick={() => handleLinkClick(link.url)} title={link.url}>
194+
{selectedLinks.map((link, index) => (
195+
<LinkPill
196+
key={link.id}
197+
$isDragging={draggedLinkId === link.id}
198+
$dragOver={dragOverIndex === index}
199+
draggable={!disabled}
200+
onDragStart={(e) => handleDragStart(e, link.id)}
201+
onDragOver={(e) => handleDragOver(e, index)}
202+
onDragLeave={handleDragLeave}
203+
onDrop={(e) => handleDrop(e, index)}
204+
onDragEnd={handleDragEnd}
205+
onClick={(e) => handleLinkClick(link.url, e)}
206+
title={link.url}
207+
>
136208
{getDomainFromUrl(link.url)}
137209
{!disabled && (
138210
<RemoveButton
@@ -188,7 +260,7 @@ const LinkCloud = styled.div`
188260
align-items: center;
189261
`
190262

191-
const LinkPill = styled.div`
263+
const LinkPill = styled.div<{ $isDragging?: boolean; $dragOver?: boolean }>`
192264
display: inline-flex;
193265
align-items: center;
194266
gap: 0.375rem;
@@ -200,11 +272,19 @@ const LinkPill = styled.div`
200272
color: white;
201273
font-weight: 500;
202274
transition: all 0.2s ease;
203-
cursor: pointer;
275+
cursor: ${(props) => (props.draggable ? "grab" : "pointer")};
276+
opacity: ${(props) => (props.$isDragging ? 0.5 : 1)};
277+
transform: ${(props) => (props.$isDragging ? "scale(0.95)" : "scale(1)")};
278+
border-color: ${(props) =>
279+
props.$dragOver ? "rgba(255, 255, 255, 0.6)" : "rgba(255, 255, 255, 0.3)"};
204280
205281
&:hover {
206282
background-color: rgba(255, 255, 255, 0.25);
207283
}
284+
285+
&:active {
286+
cursor: ${(props) => (props.draggable ? "grabbing" : "pointer")};
287+
}
208288
`
209289

210290
const RemoveButton = styled.button`

0 commit comments

Comments
 (0)