feat(ui): enhance file browser drag-and-drop and move functionality
Docker Images / Build and push (push) Failing after 15s

Refactor the file browser to improve the user experience and reliability of
folder management and item movement.

- Implement a dedicated drag handle wrapper to improve drag-and-drop
  precision and visual feedback.
- Improve drag-and-drop event handling to prevent accidental triggers
  and ensure correct visual states during drag operations.
- Refactor the move modal logic and styling for better clarity and
  usability.
- Fix folder menu closing logic to correctly handle outside clicks.
- Update CI workflow to ensure Docker is installed before building
  images.
This commit is contained in:
Tomas Dvorak
2026-05-10 10:02:02 +02:00
parent 8336c76705
commit 19e7ed6ea1
3 changed files with 158 additions and 34 deletions
+9
View File
@@ -28,6 +28,15 @@ jobs:
id: image id: image
run: echo "repository=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" run: echo "repository=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- name: Set up Docker
run: |
if ! command -v docker &> /dev/null; then
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
rm get-docker.sh
fi
docker --version
- name: Use GitHub token for GHCR - name: Use GitHub token for GHCR
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -132,6 +132,10 @@
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
li {
position: relative;
}
} }
.folderItem { .folderItem {
@@ -173,11 +177,27 @@
} }
} }
.dragHandle { .dragHandleWrapper {
cursor: grab; cursor: grab;
display: flex;
align-items: center;
padding: 2px;
border-radius: var(--border-radius-sm);
&:hover {
background: var(--color-surface-low);
}
&:active {
cursor: grabbing;
}
}
.dragHandle {
color: var(--color-muted); color: var(--color-muted);
opacity: 0; opacity: 0;
transition: opacity var(--duration-fast) var(--ease-out); transition: opacity var(--duration-fast) var(--ease-out);
flex-shrink: 0;
.folderItem:hover & { .folderItem:hover & {
opacity: 1; opacity: 1;
@@ -186,7 +206,7 @@
.dragOver { .dragOver {
background: var(--color-surface-primary-container); background: var(--color-surface-primary-container);
border-color: var(--color-primary); border: 2px dashed var(--color-primary);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
} }
@@ -425,18 +445,6 @@
margin: var(--space-1) 0; margin: var(--space-1) 0;
} }
.dropdownSubmenu {
display: flex;
flex-direction: column;
}
.dropdownSubheader {
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.newProjectForm { .newProjectForm {
display: flex; display: flex;
@@ -630,3 +638,58 @@
&:hover { background: var(--color-primary-darker); } &:hover { background: var(--color-primary-darker); }
&:disabled { opacity: 0.6; cursor: not-allowed; } &:disabled { opacity: 0.6; cursor: not-allowed; }
} }
.moveHint {
font-size: var(--text-sm);
color: var(--color-muted);
margin: 0 0 var(--space-3);
}
.moveList {
display: flex;
flex-direction: column;
gap: var(--space-1);
max-height: 320px;
overflow-y: auto;
}
.moveItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
background: none;
border: 1px solid transparent;
border-radius: var(--border-radius-lg);
cursor: pointer;
text-align: left;
color: var(--color-on-surface);
font-size: var(--text-sm);
transition: all var(--duration-fast) var(--ease-out);
svg {
color: var(--color-primary);
flex-shrink: 0;
}
&:hover {
background: var(--color-surface-low);
border-color: var(--default-border-color);
}
&.moveItemActive {
background: var(--color-surface-primary-container);
border-color: var(--color-primary);
color: var(--color-primary-darkest);
font-weight: 500;
}
}
.moveCurrent {
margin-left: auto;
font-size: var(--text-xs);
color: var(--color-muted);
background: var(--color-surface-low);
padding: var(--space-1) var(--space-2);
border-radius: var(--border-radius-md);
}
+71 -19
View File
@@ -35,7 +35,7 @@ export const FileBrowser: React.FC = () => {
const [renameValue, setRenameValue] = useState(''); const [renameValue, setRenameValue] = useState('');
// Move state // Move state
const [movingId, setMovingId] = useState<string | null>(null); const [moveModalDrawing, setMoveModalDrawing] = useState<Drawing | null>(null);
// Folder menu state // Folder menu state
const [folderMenuId, setFolderMenuId] = useState<string | null>(null); const [folderMenuId, setFolderMenuId] = useState<string | null>(null);
@@ -227,8 +227,7 @@ export const FileBrowser: React.FC = () => {
try { try {
await api.drawings.update(drawing.id, { folder_id: folderId }); await api.drawings.update(drawing.id, { folder_id: folderId });
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, folder_id: folderId } : d)); setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, folder_id: folderId } : d));
setMovingId(null); setMoveModalDrawing(null);
setActiveMenu(null);
} catch (err) { } catch (err) {
console.error('Failed to move drawing:', err); console.error('Failed to move drawing:', err);
showModal('alert', 'Error', 'Failed to move drawing. Please try again.'); showModal('alert', 'Error', 'Failed to move drawing. Please try again.');
@@ -284,23 +283,36 @@ export const FileBrowser: React.FC = () => {
// Drag and drop handlers for folders // Drag and drop handlers for folders
const handleDragStart = (e: React.DragEvent, folderId: string) => { const handleDragStart = (e: React.DragEvent, folderId: string) => {
const target = e.target as HTMLElement;
const isHandle = target.closest(`.${styles.dragHandleWrapper}`) !== null;
if (!isHandle) {
e.preventDefault();
return;
}
setDraggedFolderId(folderId); setDraggedFolderId(folderId);
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
}; };
const handleDragOver = (e: React.DragEvent, folderId: string) => { const handleDragOver = (e: React.DragEvent, folderId: string) => {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (draggedFolderId && draggedFolderId !== folderId) { if (draggedFolderId && draggedFolderId !== folderId) {
setDragOverFolderId(folderId); setDragOverFolderId(folderId);
} }
}; };
const handleDragLeave = () => { const handleDragLeave = (e: React.DragEvent) => {
const related = e.relatedTarget as HTMLElement;
const current = e.currentTarget as HTMLElement;
if (related && current.contains(related)) {
return;
}
setDragOverFolderId(null); setDragOverFolderId(null);
}; };
const handleDrop = async (e: React.DragEvent, targetFolderId: string) => { const handleDrop = async (e: React.DragEvent, targetFolderId: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
if (!draggedFolderId || draggedFolderId === targetFolderId) { if (!draggedFolderId || draggedFolderId === targetFolderId) {
setDraggedFolderId(null); setDraggedFolderId(null);
setDragOverFolderId(null); setDragOverFolderId(null);
@@ -342,9 +354,16 @@ export const FileBrowser: React.FC = () => {
// Close menu on outside click // Close menu on outside click
useEffect(() => { useEffect(() => {
const onClick = (e: MouseEvent) => { const onClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) { const target = e.target as HTMLElement;
if (menuRef.current && !menuRef.current.contains(target)) {
setActiveMenu(null); setActiveMenu(null);
} }
if (folderMenuRef.current && !folderMenuRef.current.contains(target)) {
const isMenuBtn = target.closest(`.${styles.folderMenuBtn}`) !== null;
if (!isMenuBtn) {
setFolderMenuId(null);
}
}
}; };
document.addEventListener('mousedown', onClick); document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick); return () => document.removeEventListener('mousedown', onClick);
@@ -503,7 +522,7 @@ export const FileBrowser: React.FC = () => {
draggable draggable
onDragStart={(e) => handleDragStart(e, folder.id)} onDragStart={(e) => handleDragStart(e, folder.id)}
onDragOver={(e) => handleDragOver(e, folder.id)} onDragOver={(e) => handleDragOver(e, folder.id)}
onDragLeave={handleDragLeave} onDragLeave={(e) => handleDragLeave(e)}
onDrop={(e) => handleDrop(e, folder.id)} onDrop={(e) => handleDrop(e, folder.id)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
className={dragOverFolderId === folder.id ? styles.dragOver : ''} className={dragOverFolderId === folder.id ? styles.dragOver : ''}
@@ -532,7 +551,13 @@ export const FileBrowser: React.FC = () => {
aria-current={activeFolderId === folder.id ? 'true' : undefined} aria-current={activeFolderId === folder.id ? 'true' : undefined}
role="treeitem" role="treeitem"
> >
<GripVertical size={14} className={styles.dragHandle} aria-hidden="true" /> <span
className={styles.dragHandleWrapper}
onClick={(e) => e.stopPropagation()}
aria-hidden="true"
>
<GripVertical size={14} className={styles.dragHandle} />
</span>
<Folder size={18} aria-hidden="true" /> <Folder size={18} aria-hidden="true" />
<span>{folder.name}</span> <span>{folder.name}</span>
<button <button
@@ -655,18 +680,7 @@ export const FileBrowser: React.FC = () => {
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button> <button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button> <button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button> <button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
{movingId === drawing.id ? ( <button onClick={(e) => { e.stopPropagation(); setMoveModalDrawing(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Move to...</button>
<div className={styles.dropdownSubmenu}>
<button className={styles.dropdownSubheader}>Move to:</button>
<button onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, null); }} className={styles.dropdownItem}>All Projects</button>
{folders.map(f => (
<button key={f.id} onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, f.id); }} className={styles.dropdownItem}>{f.name}</button>
))}
<button onClick={(e) => { e.stopPropagation(); setMovingId(null); }} className={styles.dropdownItem}>Cancel</button>
</div>
) : (
<button onClick={(e) => { e.stopPropagation(); setMovingId(drawing.id); }} className={styles.dropdownItem}>Move to...</button>
)}
<div className={styles.dropdownDivider} /> <div className={styles.dropdownDivider} />
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button> <button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
</div> </div>
@@ -707,6 +721,44 @@ export const FileBrowser: React.FC = () => {
</div> </div>
</div> </div>
)} )}
{moveModalDrawing && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="move-drawing-title" onClick={(e) => { if (e.target === e.currentTarget) setMoveModalDrawing(null); }}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h3 id="move-drawing-title">Move "{moveModalDrawing.title}"</h3>
<button className={styles.modalClose} onClick={() => setMoveModalDrawing(null)} aria-label="Close">&times;</button>
</div>
<div className={styles.modalBody}>
<p className={styles.moveHint}>Select a destination:</p>
<div className={styles.moveList}>
<button
className={`${styles.moveItem} ${moveModalDrawing.folder_id === null ? styles.moveItemActive : ''}`}
onClick={() => handleMoveDrawing(moveModalDrawing, null)}
>
<Folder size={18} />
<span>All Projects</span>
{moveModalDrawing.folder_id === null && <span className={styles.moveCurrent}>Current</span>}
</button>
{folders.map((f) => (
<button
key={f.id}
className={`${styles.moveItem} ${moveModalDrawing.folder_id === f.id ? styles.moveItemActive : ''}`}
onClick={() => handleMoveDrawing(moveModalDrawing, f.id)}
>
<Folder size={18} />
<span>{f.name}</span>
{moveModalDrawing.folder_id === f.id && <span className={styles.moveCurrent}>Current</span>}
</button>
))}
</div>
</div>
<div className={styles.modalFooter}>
<button className={styles.modalBtnSecondary} onClick={() => setMoveModalDrawing(null)}>Cancel</button>
</div>
</div>
</div>
)}
</div> </div>
</> </>
); );