From 02bd302b4a18b9d0e522b2bf80f84bb56086c58c Mon Sep 17 00:00:00 2001 From: Reece Date: Thu, 12 Mar 2026 01:09:14 +0000 Subject: [PATCH] Watch homepage improvements --- .../smartFolders/CardExpansionModal.tsx | 7 +- .../smartFolders/SmartFolderCard.tsx | 33 +- .../smartFolders/SmartFolderHomePage.tsx | 36 +- .../SmartFolderManagementModal.tsx | 6 +- .../smartFolders/SmartFolderSection.tsx | 5 + .../smartFolders/SmartFolderSidebarPanel.tsx | 58 +-- .../smartFolders/SmartFolderWorkbenchView.tsx | 143 +++--- .../smartFolders/WatchFolderFileList.tsx | 426 ++++++++++++++++++ .../tools/automate/ToolSelector.tsx | 71 ++- .../src/core/hooks/useFolderMembership.ts | 19 +- frontend/src/core/services/fileStorage.ts | 1 + frontend/src/core/types/smartFolders.ts | 1 + 12 files changed, 669 insertions(+), 137 deletions(-) create mode 100644 frontend/src/core/components/smartFolders/WatchFolderFileList.tsx diff --git a/frontend/src/core/components/smartFolders/CardExpansionModal.tsx b/frontend/src/core/components/smartFolders/CardExpansionModal.tsx index faf7086f8f..da56dba391 100644 --- a/frontend/src/core/components/smartFolders/CardExpansionModal.tsx +++ b/frontend/src/core/components/smartFolders/CardExpansionModal.tsx @@ -96,7 +96,7 @@ export function CardExpansionModal({ willChange: 'top, left, width, height, opacity', borderRadius: 'var(--mantine-radius-md)', overflow: 'hidden', - backgroundColor: 'var(--mantine-color-body)', + backgroundColor: 'var(--bg-toolbar)', display: 'flex', flexDirection: 'column', boxShadow: '0 1.5rem 3rem rgba(0,0,0,0.3)', @@ -150,9 +150,9 @@ export function CardExpansionModal({ {/* Body — only mount once open */} {showBody && ( -
+
-
+
{children}
@@ -160,7 +160,6 @@ export function CardExpansionModal({
{footer} diff --git a/frontend/src/core/components/smartFolders/SmartFolderCard.tsx b/frontend/src/core/components/smartFolders/SmartFolderCard.tsx index 4d05df73a9..808175ebb1 100644 --- a/frontend/src/core/components/smartFolders/SmartFolderCard.tsx +++ b/frontend/src/core/components/smartFolders/SmartFolderCard.tsx @@ -15,18 +15,49 @@ interface SmartFolderCardProps { onSelect: () => void; onEdit: (e: React.MouseEvent) => void; onDelete: (e: React.MouseEvent) => void; + onFileDrop?: (fileIds: string[]) => void; } -export function SmartFolderCard({ folder, isActive, status, onSelect, onEdit, onDelete }: SmartFolderCardProps) { +export function SmartFolderCard({ folder, isActive, status, onSelect, onEdit, onDelete, onFileDrop }: SmartFolderCardProps) { const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); const IconComponent = iconMap[folder.icon as keyof typeof iconMap] || iconMap.FolderIcon; + const handleDragOver = (e: React.DragEvent) => { + const types = e.dataTransfer.types; + if (!types.includes('watchfolderfileid') && !types.includes('watchfolderfileids')) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + setIsDragOver(true); + }; + + const handleDragLeave = () => setIsDragOver(false); + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const multiRaw = e.dataTransfer.getData('watchFolderFileIds'); + if (multiRaw) { + try { + const ids: string[] = JSON.parse(multiRaw); + if (ids.length > 0 && onFileDrop) onFileDrop(ids); + return; + } catch { /* fall through */ } + } + const fileId = e.dataTransfer.getData('watchFolderFileId'); + if (fileId && onFileDrop) onFileDrop([fileId]); + }; + return ( setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + style={isDragOver ? { backgroundColor: `${folder.accentColor}18`, borderRadius: 'var(--mantine-radius-sm)' } : undefined} > + ); + })} + {folders.filter(f => !memberFolderIds.includes(f.id)).length === 0 && ( + + {memberFolderIds.length > 0 + ? t('smartFolders.fileList.inAllFolders', 'Already in all folders') + : t('smartFolders.noFolders', 'No watch folders yet')} + + )} + + + + )} + + ); +} + +function SectionHeader({ label, count }: { label: string; count: number }) { + return ( + + + {label} + + + {count} + + + ); +} + +export function WatchFolderFileList({ files, folderId, onSendToFolder, onNavigateToFolder }: WatchFolderFileListProps) { + const { t } = useTranslation(); + const membership = useFolderMembership(); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const lastSelectedIdxRef = useRef(null); + // Ref for drag ghost cleanup + const ghostRef = useRef(null); + + const unassigned = files.filter(f => !membership.has(f.id)); + const inFolders = files.filter(f => membership.has(f.id)); + + // All files in display order (unassigned first, then in-folders) + const orderedFiles = [...unassigned, ...inFolders]; + + const handleToggleSelect = useCallback((idx: number, shiftKey: boolean) => { + const file = orderedFiles[idx]; + if (!file) return; + setSelectedIds(prev => { + const next = new Set(prev); + if (shiftKey && lastSelectedIdxRef.current !== null) { + // Range select + const start = Math.min(lastSelectedIdxRef.current, idx); + const end = Math.max(lastSelectedIdxRef.current, idx); + for (let i = start; i <= end; i++) { + if (orderedFiles[i]) next.add(orderedFiles[i].id); + } + } else { + if (next.has(file.id)) { + next.delete(file.id); + } else { + next.add(file.id); + } + } + return next; + }); + lastSelectedIdxRef.current = idx; + }, [orderedFiles]); + + const clearSelection = useCallback(() => { + setSelectedIds(new Set()); + lastSelectedIdxRef.current = null; + }, []); + + // Escape key to clear selection + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') clearSelection(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [clearSelection]); + + const handleDragStart = useCallback((e: React.DragEvent, file: StirlingFileStub) => { + const isFileSelected = selectedIds.has(file.id); + const isMulti = isFileSelected && selectedIds.size > 1; + + if (isMulti) { + const ids = Array.from(selectedIds); + e.dataTransfer.setData('watchFolderFileIds', JSON.stringify(ids)); + // Also write single as fallback for older handlers + e.dataTransfer.setData('watchFolderFileId', ids[0]); + + // Custom ghost showing "N files" + const ghost = document.createElement('div'); + ghost.style.cssText = [ + 'position:fixed', + 'top:-9999px', + 'left:-9999px', + 'padding:0.25rem 0.75rem', + 'border-radius:0.5rem', + 'background:var(--mantine-color-blue-filled,#3b82f6)', + 'color:#fff', + 'font-size:0.75rem', + 'font-weight:600', + 'white-space:nowrap', + 'pointer-events:none', + 'z-index:9999', + 'font-family:var(--mantine-font-family,sans-serif)', + ].join(';'); + ghost.textContent = `${ids.length} files`; + document.body.appendChild(ghost); + ghostRef.current = ghost; + e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2); + + e.dataTransfer.effectAllowed = 'copy'; + } else { + e.dataTransfer.setData('watchFolderFileId', file.id); + e.dataTransfer.effectAllowed = 'copy'; + } + }, [selectedIds]); + + // Clean up ghost and clear selection after drag ends + const handleDragEnd = useCallback(() => { + if (ghostRef.current) { + document.body.removeChild(ghostRef.current); + ghostRef.current = null; + } + clearSelection(); + }, [clearSelection]); + + if (files.length === 0) { + return ( + + {t('smartFolders.fileList.empty', 'No files yet — upload a PDF to get started')} + + ); + } + + return ( + + {/* Selection count pill */} + {selectedIds.size >= 2 && ( + + + {selectedIds.size} {t('smartFolders.fileList.selected', 'selected')} + + + + + + )} + + {unassigned.length > 0 && ( + <> + {inFolders.length > 0 && ( + + )} + {unassigned.map((file, i) => ( + + ))} + + )} + + {inFolders.length > 0 && ( + <> + + {inFolders.map((file, i) => ( + + ))} + + )} + + ); +} diff --git a/frontend/src/core/components/tools/automate/ToolSelector.tsx b/frontend/src/core/components/tools/automate/ToolSelector.tsx index 2d87ae69a9..f6fb6716ff 100644 --- a/frontend/src/core/components/tools/automate/ToolSelector.tsx +++ b/frontend/src/core/components/tools/automate/ToolSelector.tsx @@ -1,4 +1,5 @@ import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { Stack, Text, ScrollArea } from '@mantine/core'; import { ToolRegistryEntry, ToolRegistry, getToolSupportsAutomate } from '@app/data/toolsTaxonomy'; @@ -28,7 +29,9 @@ export default function ToolSelector({ const [opened, setOpened] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [shouldAutoFocus, setShouldAutoFocus] = useState(false); + const [dropdownRect, setDropdownRect] = useState(null); const containerRef = useRef(null); + const dropdownRef = useRef(null); // Filter out excluded tools (like 'automate' itself) and tools that don't support automation const baseFilteredTools = useMemo(() => { @@ -111,10 +114,28 @@ export default function ToolSelector({ setShouldAutoFocus(true); // Request auto-focus for the input }; + // Track container rect for portal positioning + useEffect(() => { + if (!opened) return; + const updateRect = () => { + if (containerRef.current) setDropdownRect(containerRef.current.getBoundingClientRect()); + }; + updateRect(); + window.addEventListener('scroll', updateRect, true); + window.addEventListener('resize', updateRect); + return () => { + window.removeEventListener('scroll', updateRect, true); + window.removeEventListener('resize', updateRect); + }; + }, [opened]); + // Handle click outside to close dropdown useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + const target = event.target as Node; + const inContainer = containerRef.current?.contains(target); + const inDropdown = dropdownRef.current?.contains(target); + if (!inContainer && !inDropdown) { setOpened(false); setSearchTerm(''); } @@ -181,24 +202,31 @@ export default function ToolSelector({ /> )} - {/* Custom dropdown */} - {opened && ( -
- + {/* Custom dropdown — rendered in a portal so it escapes modal overflow */} + {opened && dropdownRect && createPortal( + (() => { + const spaceBelow = window.innerHeight - dropdownRect.bottom - 8; + const spaceAbove = dropdownRect.top - 8; + const maxH = Math.max(120, Math.min(350, spaceBelow > 120 ? spaceBelow : spaceAbove)); + const openUpward = spaceBelow < 120 && spaceAbove > spaceBelow; + const top = openUpward ? dropdownRect.top - maxH - 4 : dropdownRect.bottom + 4; + return ( +
+ {displayGroups.length === 0 ? ( @@ -212,7 +240,10 @@ export default function ToolSelector({ )} -
+
+ ); + })(), + document.body )}
); diff --git a/frontend/src/core/hooks/useFolderMembership.ts b/frontend/src/core/hooks/useFolderMembership.ts index 8734d0e39d..7d612ff9e0 100644 --- a/frontend/src/core/hooks/useFolderMembership.ts +++ b/frontend/src/core/hooks/useFolderMembership.ts @@ -8,9 +8,9 @@ import { useState, useEffect } from 'react'; import { folderStorage } from '@app/services/folderStorage'; import { useAllSmartFolders } from '@app/hooks/useAllSmartFolders'; -export function useFolderMembership(): Map { +export function useFolderMembership(): Map { const folders = useAllSmartFolders(); - const [membership, setMembership] = useState>(new Map()); + const [membership, setMembership] = useState>(new Map()); useEffect(() => { if (folders.length === 0) { @@ -19,13 +19,19 @@ export function useFolderMembership(): Map { } const load = async () => { - const map = new Map(); + const map = new Map(); + const add = (fileId: string, folderId: string) => { + const existing = map.get(fileId); + if (existing) { if (!existing.includes(folderId)) existing.push(folderId); } + else map.set(fileId, [folderId]); + }; for (const folder of folders) { try { const record = await folderStorage.getFolderData(folder.id); if (record) { - Object.keys(record.files).forEach(fileId => { - map.set(fileId, folder.id); + Object.entries(record.files).forEach(([fileId, meta]) => { + add(fileId, folder.id); + if (meta?.originalFileId) add(meta.originalFileId, folder.id); }); } } catch { @@ -36,6 +42,9 @@ export function useFolderMembership(): Map { }; load(); + + window.addEventListener('folder-storage-changed', load); + return () => window.removeEventListener('folder-storage-changed', load); }, [folders]); return membership; diff --git a/frontend/src/core/services/fileStorage.ts b/frontend/src/core/services/fileStorage.ts index 3668f5cf2d..c3cffa32bc 100644 --- a/frontend/src/core/services/fileStorage.ts +++ b/frontend/src/core/services/fileStorage.ts @@ -81,6 +81,7 @@ class FileStorageService { reject(request.error); }; request.onsuccess = () => { + window.dispatchEvent(new CustomEvent('stirling:files-changed')); resolve(); }; } catch (error) { diff --git a/frontend/src/core/types/smartFolders.ts b/frontend/src/core/types/smartFolders.ts index a2644a522e..e23eaccc1c 100644 --- a/frontend/src/core/types/smartFolders.ts +++ b/frontend/src/core/types/smartFolders.ts @@ -22,6 +22,7 @@ export interface FolderFileMetadata { processedAt?: Date; displayFileId?: string; // output file id inputFileId?: string; + originalFileId?: string; // original StirlingFile UUID (when sent from sidebar) errorMessage?: string; failedAttempts?: number; name?: string; // original filename