DMS: SourceTree drop targets + draggable folders

This commit is contained in:
2026-07-20 22:33:51 +00:00
parent d5a836c0c7
commit f08933a97c
+96 -8
View File
@@ -4,6 +4,8 @@
* ▼ Meine Dateien (local folders) * ▼ Meine Dateien (local folders)
* ▼ Geteilte Ordner (shared files from other users) * ▼ Geteilte Ordner (shared files from other users)
* ▼ Externe Speicher (placeholder for future plugins) * ▼ Externe Speicher (placeholder for future plugins)
*
* Folders are drop targets (accept files) and draggable (for reparenting).
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
@@ -16,6 +18,8 @@ interface SourceTreeFolderItemProps {
level: number; level: number;
selectedFolderId: string | null; selectedFolderId: string | null;
onSelect: (folderId: string) => void; onSelect: (folderId: string) => void;
onDropFiles: (fileIds: string[], targetFolderId: string) => void;
onDropFolder: (sourceFolderId: string, targetFolderId: string) => void;
} }
function SourceTreeFolderItem({ function SourceTreeFolderItem({
@@ -23,20 +27,60 @@ function SourceTreeFolderItem({
level, level,
selectedFolderId, selectedFolderId,
onSelect, onSelect,
onDropFiles,
onDropFolder,
}: SourceTreeFolderItemProps) { }: SourceTreeFolderItemProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const [isDropTarget, setIsDropTarget] = useState(false);
const hasChildren = folder.children && folder.children.length > 0; const hasChildren = folder.children && folder.children.length > 0;
const isSelected = selectedFolderId === folder.id; const isSelected = selectedFolderId === folder.id;
function handleDragOver(e: React.DragEvent) {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
if (!isDropTarget) setIsDropTarget(true);
}
function handleDragLeave(e: React.DragEvent) {
e.stopPropagation();
setIsDropTarget(false);
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
e.stopPropagation();
setIsDropTarget(false);
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'));
if (data.type === 'files' && data.ids) {
onDropFiles(data.ids, folder.id);
} else if (data.type === 'folder' && data.id) {
if (data.id !== folder.id) {
onDropFolder(data.id, folder.id);
}
}
} catch {
// ignore invalid drop data
}
}
function handleFolderDragStart(e: React.DragEvent) {
e.stopPropagation();
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'folder', id: folder.id }));
e.dataTransfer.effectAllowed = 'move';
}
return ( return (
<li role="treeitem" aria-expanded={hasChildren ? expanded : undefined} aria-selected={isSelected}> <li role="treeitem" aria-expanded={hasChildren ? expanded : undefined} aria-selected={isSelected}>
<div <div
className={clsx( className={clsx(
'flex items-center gap-1 px-2 py-1.5 rounded-md text-sm cursor-pointer min-h-touch', 'flex items-center gap-1 px-2 py-1.5 rounded-md text-sm cursor-pointer',
'hover:bg-secondary-100 motion-safe:transition-colors', 'hover:bg-secondary-100 motion-safe:transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500', 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isSelected && 'bg-primary-50 text-primary-700 font-medium', isSelected && 'bg-primary-50 text-primary-700 font-medium',
isDropTarget && 'ring-2 ring-primary-400 bg-primary-50',
)} )}
style={{ paddingLeft: `${level * 12 + 8}px` }} style={{ paddingLeft: `${level * 12 + 8}px` }}
onClick={() => onSelect(folder.id)} onClick={() => onSelect(folder.id)}
@@ -49,6 +93,11 @@ function SourceTreeFolderItem({
tabIndex={0} tabIndex={0}
role="button" role="button"
aria-label={folder.name} aria-label={folder.name}
draggable
onDragStart={handleFolderDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
> >
{hasChildren && ( {hasChildren && (
<button <button
@@ -66,7 +115,7 @@ function SourceTreeFolderItem({
</button> </button>
)} )}
{!hasChildren && <span className="w-4 flex-shrink-0" aria-hidden="true" />} {!hasChildren && <span className="w-4 flex-shrink-0" aria-hidden="true" />}
<svg className="w-4 h-4 text-warning-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <svg className={clsx('w-4 h-4 flex-shrink-0', isDropTarget ? 'text-primary-500' : 'text-warning-500')} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg> </svg>
<span className="truncate">{folder.name}</span> <span className="truncate">{folder.name}</span>
@@ -75,7 +124,7 @@ function SourceTreeFolderItem({
)} )}
</div> </div>
{hasChildren && expanded && ( {hasChildren && expanded && (
<ul role="group" className="space-y-0.5"> <ul role="group" className="space-y-0">
{folder.children!.map((child) => ( {folder.children!.map((child) => (
<SourceTreeFolderItem <SourceTreeFolderItem
key={child.id} key={child.id}
@@ -83,6 +132,8 @@ function SourceTreeFolderItem({
level={level + 1} level={level + 1}
selectedFolderId={selectedFolderId} selectedFolderId={selectedFolderId}
onSelect={onSelect} onSelect={onSelect}
onDropFiles={onDropFiles}
onDropFolder={onDropFolder}
/> />
))} ))}
</ul> </ul>
@@ -107,7 +158,7 @@ function SourceSection({ title, icon, selected, onClick, children, defaultExpand
<div className="mb-1"> <div className="mb-1">
<div <div
className={clsx( className={clsx(
'flex items-center gap-1 px-2 py-1.5 rounded-md text-sm font-semibold cursor-pointer min-h-touch', 'flex items-center gap-1 px-2 py-1.5 rounded-md text-sm font-semibold cursor-pointer',
'hover:bg-secondary-100 motion-safe:transition-colors', 'hover:bg-secondary-100 motion-safe:transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500', 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
selected && 'bg-primary-50 text-primary-700', selected && 'bg-primary-50 text-primary-700',
@@ -144,7 +195,7 @@ function SourceSection({ title, icon, selected, onClick, children, defaultExpand
<span className="truncate">{title}</span> <span className="truncate">{title}</span>
</div> </div>
{expanded && children && ( {expanded && children && (
<ul role="group" className="space-y-0.5 mt-0.5"> <ul role="group" className="space-y-0 mt-0.5">
{children} {children}
</ul> </ul>
)} )}
@@ -160,6 +211,8 @@ export interface SourceTreeProps {
onSelectFolder: (folderId: string | null) => void; onSelectFolder: (folderId: string | null) => void;
onSelectSource: (source: string) => void; onSelectSource: (source: string) => void;
loading?: boolean; loading?: boolean;
onDropFiles: (fileIds: string[], targetFolderId: string | null) => void;
onDropFolder: (sourceFolderId: string, targetFolderId: string | null) => void;
} }
export function SourceTree({ export function SourceTree({
@@ -170,8 +223,11 @@ export function SourceTree({
onSelectFolder, onSelectFolder,
onSelectSource, onSelectSource,
loading = false, loading = false,
onDropFiles,
onDropFolder,
}: SourceTreeProps) { }: SourceTreeProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isRootDropTarget, setIsRootDropTarget] = useState(false);
if (loading) { if (loading) {
return ( return (
@@ -187,15 +243,42 @@ export function SourceTree({
const sharedSelected = selectedSource === 'shared'; const sharedSelected = selectedSource === 'shared';
const externalSelected = selectedSource === 'external'; const externalSelected = selectedSource === 'external';
function handleRootDragOver(e: React.DragEvent) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!isRootDropTarget) setIsRootDropTarget(true);
}
function handleRootDragLeave(e: React.DragEvent) {
e.stopPropagation();
setIsRootDropTarget(false);
}
function handleRootDrop(e: React.DragEvent) {
e.preventDefault();
setIsRootDropTarget(false);
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'));
if (data.type === 'files' && data.ids) {
onDropFiles(data.ids, null);
} else if (data.type === 'folder' && data.id) {
onDropFolder(data.id, null);
}
} catch {
// ignore invalid drop data
}
}
return ( return (
<nav aria-label={t('dms.sources')} data-testid="source-tree" className="p-2"> <nav aria-label={t('dms.sources')} data-testid="source-tree" className="p-2">
{/* All Files entry */} {/* All Files entry — also a drop target (move to root) */}
<div <div
className={clsx( className={clsx(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-sm cursor-pointer min-h-touch mb-1', 'flex items-center gap-2 px-2 py-1.5 rounded-md text-sm cursor-pointer mb-1',
'hover:bg-secondary-100 motion-safe:transition-colors', 'hover:bg-secondary-100 motion-safe:transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500', 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
selectedSource === null && selectedFolderId === null && 'bg-primary-50 text-primary-700 font-medium', selectedSource === null && selectedFolderId === null && 'bg-primary-50 text-primary-700 font-medium',
isRootDropTarget && 'ring-2 ring-primary-400 bg-primary-50',
)} )}
onClick={() => { onClick={() => {
onSelectFolder(null); onSelectFolder(null);
@@ -208,6 +291,9 @@ export function SourceTree({
onSelectSource('local'); onSelectSource('local');
} }
}} }}
onDragOver={handleRootDragOver}
onDragLeave={handleRootDragLeave}
onDrop={handleRootDrop}
tabIndex={0} tabIndex={0}
role="button" role="button"
aria-label={t('dms.allFiles')} aria-label={t('dms.allFiles')}
@@ -244,6 +330,8 @@ export function SourceTree({
level={0} level={0}
selectedFolderId={selectedFolderId} selectedFolderId={selectedFolderId}
onSelect={onSelectFolder} onSelect={onSelectFolder}
onDropFiles={onDropFiles}
onDropFolder={onDropFolder}
/> />
)) ))
)} )}
@@ -270,7 +358,7 @@ export function SourceTree({
<li key={file.id} role="treeitem" aria-selected={false}> <li key={file.id} role="treeitem" aria-selected={false}>
<div <div
className={clsx( className={clsx(
'flex items-center gap-1 px-2 py-1.5 rounded-md text-sm cursor-pointer min-h-touch', 'flex items-center gap-1 px-2 py-1.5 rounded-md text-sm cursor-pointer',
'hover:bg-secondary-100 motion-safe:transition-colors', 'hover:bg-secondary-100 motion-safe:transition-colors',
)} )}
style={{ paddingLeft: '20px' }} style={{ paddingLeft: '20px' }}