Watch folders set up
This commit is contained in:
@@ -2972,6 +2972,7 @@ addFiles = "Add Files"
|
||||
|
||||
[fileManager]
|
||||
active = "Active"
|
||||
addToSmartFolder = "Add to Watch Folder"
|
||||
addToUpload = "Add to Upload"
|
||||
clearAll = "Clear All"
|
||||
clearSelection = "Clear Selection"
|
||||
@@ -4926,6 +4927,7 @@ help = "Help"
|
||||
read = "Read"
|
||||
reader = "Reader"
|
||||
settings = "Settings"
|
||||
watchFolders = "Watch Folders"
|
||||
showMeAround = "Show me around"
|
||||
sign = "Sign"
|
||||
tours = "Tours"
|
||||
@@ -6972,8 +6974,8 @@ deleteConfirmTitle = "Delete folder?"
|
||||
defaultFolderWarning = "This is a default folder and will be recreated on next reload."
|
||||
folderNotFound = "Folder not found"
|
||||
newFolder = "New folder"
|
||||
noFolders = "No smart folders yet"
|
||||
title = "Smart Folders"
|
||||
noFolders = "No watch folders yet"
|
||||
title = "Watch Folders"
|
||||
|
||||
[smartFolders.modal]
|
||||
automation = "Automation"
|
||||
@@ -6981,17 +6983,31 @@ automationSaved = "Automation saved"
|
||||
createFolder = "Create Folder"
|
||||
stepsSaved = "Steps saved — click Create Folder to finish"
|
||||
color = "Accent color"
|
||||
createTitle = "New Smart Folder"
|
||||
createTitle = "New Watch Folder"
|
||||
description = "Description"
|
||||
descriptionPlaceholder = "What does this folder do?"
|
||||
editTitle = "Edit Smart Folder"
|
||||
editTitle = "Edit Watch Folder"
|
||||
icon = "Icon"
|
||||
name = "Folder name"
|
||||
namePlaceholder = "My Smart Folder"
|
||||
namePlaceholder = "My Watch Folder"
|
||||
nameRequired = "Folder name is required"
|
||||
nameTooLong = "Folder name must be 50 characters or less"
|
||||
saveChanges = "Save Changes"
|
||||
|
||||
[smartFolders.home]
|
||||
create = "Create your first folder"
|
||||
dropHere = "Drop to process"
|
||||
editFolder = "Edit folder"
|
||||
empty = "No watch folders yet"
|
||||
file = "file"
|
||||
files = "files"
|
||||
openFolder = "Open folder"
|
||||
title = "Watch Folders"
|
||||
|
||||
[smartFolders.status]
|
||||
done = "Done"
|
||||
processing = "Processing"
|
||||
|
||||
[smartFolders.workbench]
|
||||
all = "All"
|
||||
download = "Download"
|
||||
@@ -7007,6 +7023,10 @@ processing = "Processing"
|
||||
processingLog = "Processing log"
|
||||
step = "Step {{step}}: {{operation}}"
|
||||
|
||||
sidebarFiles = "Your Files"
|
||||
sidebarOutputFiles = "Output Files"
|
||||
sidebarSelectFolder = "Folder Files"
|
||||
|
||||
[zipWarning]
|
||||
cancel = "Cancel"
|
||||
confirm = "Extract"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import HomePage from "@app/pages/HomePage";
|
||||
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||
import SmartFoldersRegistration from "@app/components/smartFolders/SmartFoldersRegistration";
|
||||
|
||||
// Import global styles
|
||||
import "@app/styles/tailwind.css";
|
||||
@@ -50,6 +51,7 @@ export default function App() {
|
||||
<AppLayout>
|
||||
<HomePage />
|
||||
<Onboarding />
|
||||
<SmartFoldersRegistration />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
||||
import AppConfigLoader from '@app/components/shared/AppConfigLoader';
|
||||
import { RedactionProvider } from "@app/contexts/RedactionContext";
|
||||
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
|
||||
import { FolderFileContextProvider } from "@app/contexts/FolderFileContext";
|
||||
|
||||
// Component to initialize scarf tracking (must be inside AppConfigProvider)
|
||||
function ScarfTrackingInitializer() {
|
||||
@@ -123,7 +124,9 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
<FolderFileContextProvider>
|
||||
{children}
|
||||
</FolderFileContextProvider>
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
|
||||
@@ -16,6 +16,10 @@ import ToolChain from '@app/components/shared/ToolChain';
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import { useFileManagement } from '@app/contexts/FileContext';
|
||||
import { useAllSmartFolders } from '@app/hooks/useAllSmartFolders';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { iconMap } from '@app/components/tools/automate/iconMap';
|
||||
|
||||
interface FileListItemProps {
|
||||
file: StirlingFileStub;
|
||||
@@ -48,6 +52,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const { expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
|
||||
const { removeFiles } = useFileManagement();
|
||||
const smartFolders = useAllSmartFolders();
|
||||
const { setCustomWorkbenchViewData } = useToolWorkflow();
|
||||
const { actions } = useNavigationActions();
|
||||
|
||||
const isPdf = file.name?.toLowerCase().endsWith('.pdf') ?? false;
|
||||
const showSmartFolders = isPdf && smartFolders.length > 0;
|
||||
|
||||
// Check if this is a ZIP file
|
||||
const isZipFile = zipFileService.isZipFileStub(file);
|
||||
@@ -260,6 +270,30 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{showSmartFolders && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Label>{t('fileManager.addToSmartFolder', 'Add to Watch Folder')}</Menu.Label>
|
||||
{smartFolders.map((folder) => {
|
||||
const FolderItemIcon = iconMap[folder.icon as keyof typeof iconMap] || iconMap.FolderIcon;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={folder.id}
|
||||
leftSection={<FolderItemIcon style={{ fontSize: 16, color: folder.accentColor }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCustomWorkbenchViewData('smartFolder', { folderId: folder.id, pendingFileId: file.id });
|
||||
actions.setWorkbench('custom:smartFolder');
|
||||
}}
|
||||
>
|
||||
{folder.name}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvi
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { SMART_FOLDER_VIEW_ID, SMART_FOLDER_WORKBENCH_ID } from '@app/components/smartFolders/SmartFoldersRegistration';
|
||||
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
|
||||
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
|
||||
import { ButtonConfig } from '@app/types/sidebar';
|
||||
@@ -15,6 +16,7 @@ import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import AllToolsNavButton from '@app/components/shared/AllToolsNavButton';
|
||||
import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton";
|
||||
import AppConfigModal from '@app/components/shared/AppConfigModal';
|
||||
import FolderSpecialRoundedIcon from '@mui/icons-material/FolderSpecialRounded';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
|
||||
import { requestStartTour } from '@app/constants/events';
|
||||
@@ -34,8 +36,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const location = useLocation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool, toolAvailability } = useToolWorkflow();
|
||||
const { hasUnsavedChanges } = useNavigationState();
|
||||
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool, toolAvailability, setCustomWorkbenchViewData } = useToolWorkflow();
|
||||
const { hasUnsavedChanges, workbench } = useNavigationState();
|
||||
const { actions: navigationActions } = useNavigationActions();
|
||||
const { getToolNavigation } = useSidebarNavigation();
|
||||
const { config } = useAppConfig();
|
||||
@@ -61,9 +63,13 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workbench === SMART_FOLDER_WORKBENCH_ID) {
|
||||
setActiveButton('watchFolders');
|
||||
return;
|
||||
}
|
||||
const next = getActiveNavButton(selectedToolKey, readerMode);
|
||||
setActiveButton(next);
|
||||
}, [leftPanelView, selectedToolKey, toolRegistry, readerMode]);
|
||||
}, [leftPanelView, selectedToolKey, toolRegistry, readerMode, workbench]);
|
||||
|
||||
const handleFilesButtonClick = () => {
|
||||
openFilesModal();
|
||||
@@ -158,6 +164,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
return availability?.available !== false;
|
||||
}), [t, setActiveButton, handleReaderToggle, selectedToolKey, resetTool, handleToolSelect, toolAvailability]);
|
||||
|
||||
const isWatchFoldersActive = workbench === SMART_FOLDER_WORKBENCH_ID;
|
||||
|
||||
const middleButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'files',
|
||||
@@ -168,6 +176,19 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
type: 'modal',
|
||||
onClick: handleFilesButtonClick
|
||||
},
|
||||
{
|
||||
id: 'watchFolders',
|
||||
name: t("quickAccess.watchFolders", "Watch Folders"),
|
||||
icon: <FolderSpecialRoundedIcon style={{ width: '1.25rem', height: '1.25rem' }} />,
|
||||
isRound: true,
|
||||
size: 'md',
|
||||
type: 'navigation',
|
||||
onClick: () => {
|
||||
setActiveButton('watchFolders');
|
||||
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId: null });
|
||||
navigationActions.setWorkbench(SMART_FOLDER_WORKBENCH_ID);
|
||||
}
|
||||
},
|
||||
];
|
||||
//TODO: Activity
|
||||
//{
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Icon picker for Smart Folder create/edit.
|
||||
* Re-exports IconSelector from the automate tools so smart folder components
|
||||
* don't need to reach into the automate tools directory.
|
||||
*/
|
||||
|
||||
export { default as IconPicker } from '@app/components/tools/automate/IconSelector';
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Button, Text, ActionIcon, Group, Loader } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import { SmartFolder } from '@app/types/smartFolders';
|
||||
import { FolderRunStatus } from '@app/hooks/useFolderRunStatuses';
|
||||
import { iconMap } from '@app/components/tools/automate/iconMap';
|
||||
|
||||
interface SmartFolderCardProps {
|
||||
folder: SmartFolder;
|
||||
isActive: boolean;
|
||||
status: FolderRunStatus;
|
||||
onSelect: () => void;
|
||||
onEdit: (e: React.MouseEvent) => void;
|
||||
onDelete: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function SmartFolderCard({ folder, isActive, status, onSelect, onEdit, onDelete }: SmartFolderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const IconComponent = iconMap[folder.icon as keyof typeof iconMap] || iconMap.FolderIcon;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="tool-button-container"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Button
|
||||
variant={isActive ? 'light' : 'subtle'}
|
||||
className="tool-button"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
px="sm"
|
||||
leftSection={
|
||||
<Box
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: `${folder.accentColor}22`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IconComponent style={{ fontSize: 11, color: folder.accentColor }} />
|
||||
</Box>
|
||||
}
|
||||
rightSection={
|
||||
isHovered ? (
|
||||
<Group gap={2} onClick={(e) => e.stopPropagation()}>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={onEdit}
|
||||
aria-label={t('smartFolders.card.edit', 'Edit folder')}
|
||||
>
|
||||
<EditIcon style={{ fontSize: 11 }} />
|
||||
</ActionIcon>
|
||||
{!folder.isDefault && (
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={onDelete}
|
||||
aria-label={t('smartFolders.card.delete', 'Delete folder')}
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: 11 }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
) : status === 'processing' ? (
|
||||
<Loader size={10} color={folder.accentColor} />
|
||||
) : status === 'done' ? (
|
||||
<CheckCircleIcon style={{ fontSize: 12, color: 'var(--mantine-color-teal-6)' }} />
|
||||
) : null
|
||||
}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<Text size="sm" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{folder.name}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Box, Text, Stack, Group, ActionIcon, Badge, Button, Loader, ScrollArea } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import { useSmartFolders } from '@app/hooks/useSmartFolders';
|
||||
import { useFolderRunStatuses } from '@app/hooks/useFolderRunStatuses';
|
||||
import { SmartFolder, SmartFolderRunEntry } from '@app/types/smartFolders';
|
||||
import { AutomationConfig } from '@app/types/automation';
|
||||
import { iconMap } from '@app/components/tools/automate/iconMap';
|
||||
import { automationStorage } from '@app/services/automationStorage';
|
||||
import { folderStorage } from '@app/services/folderStorage';
|
||||
import { folderRunStateStorage } from '@app/services/folderRunStateStorage';
|
||||
import { executeAutomationSequence } from '@app/utils/automationExecutor';
|
||||
import { SmartFolderManagementModal } from '@app/components/smartFolders/SmartFolderManagementModal';
|
||||
import { DeleteFolderConfirmModal } from '@app/components/smartFolders/DeleteFolderConfirmModal';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { SMART_FOLDER_VIEW_ID, SMART_FOLDER_WORKBENCH_ID } from '@app/components/smartFolders/SmartFoldersRegistration';
|
||||
|
||||
// Humanise an operation key like "compress-pdf" → "Compress PDF"
|
||||
function humaniseOp(op: string): string {
|
||||
return op
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
interface FolderCardProps {
|
||||
folder: SmartFolder;
|
||||
status: 'idle' | 'processing' | 'done';
|
||||
isProcessing: boolean;
|
||||
onEdit: (folder: SmartFolder) => void;
|
||||
onOpen: (folderId: string) => void;
|
||||
onDropFiles: (folder: SmartFolder, files: File[]) => void;
|
||||
}
|
||||
|
||||
function FolderCard({ folder, status, isProcessing, onEdit, onOpen, onDropFiles }: FolderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [automation, setAutomation] = useState<AutomationConfig | null>(null);
|
||||
const [fileCount, setFileCount] = useState(0);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
automationStorage.getAutomation(folder.automationId).then(setAutomation);
|
||||
|
||||
const loadCount = () =>
|
||||
folderStorage.getFolderData(folder.id).then((record) => {
|
||||
setFileCount(record ? Object.keys(record.files).length : 0);
|
||||
});
|
||||
loadCount();
|
||||
|
||||
const unsub = folderStorage.onFolderChange((changedId) => {
|
||||
if (changedId === folder.id) loadCount();
|
||||
});
|
||||
return unsub;
|
||||
}, [folder.id, folder.automationId]);
|
||||
|
||||
const FolderIcon = iconMap[folder.icon as keyof typeof iconMap] ?? iconMap.FolderIcon;
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragOver(false);
|
||||
};
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
onDropFiles(folder, Array.from(e.dataTransfer.files));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
border: `2px solid ${isDragOver ? folder.accentColor : 'var(--border-subtle)'}`,
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
padding: '0.875rem 1rem',
|
||||
backgroundColor: isDragOver
|
||||
? `${folder.accentColor}15`
|
||||
: 'var(--bg-surface, var(--mantine-color-default))',
|
||||
transition: 'border-color 0.15s, background-color 0.15s',
|
||||
position: 'relative',
|
||||
cursor: 'default',
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Group align="flex-start" wrap="nowrap" gap="sm">
|
||||
{/* Icon circle */}
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: `${folder.accentColor}22`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
<FolderIcon style={{ fontSize: 20, color: folder.accentColor }} />
|
||||
</Box>
|
||||
|
||||
{/* Main content */}
|
||||
<Stack gap={5} style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* Top row: name + badges + actions */}
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" lineClamp={1} style={{ minWidth: 0 }}>
|
||||
{folder.name}
|
||||
</Text>
|
||||
{fileCount > 0 && (
|
||||
<Badge size="xs" variant="light" color="gray" style={{ flexShrink: 0 }}>
|
||||
{fileCount} {fileCount === 1
|
||||
? t('smartFolders.home.file', 'file')
|
||||
: t('smartFolders.home.files', 'files')}
|
||||
</Badge>
|
||||
)}
|
||||
{isProcessing && (
|
||||
<Badge size="xs" color="blue" variant="dot" style={{ flexShrink: 0 }}>
|
||||
{t('smartFolders.status.processing', 'Processing')}
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'done' && !isProcessing && (
|
||||
<Badge size="xs" color="teal" variant="dot" style={{ flexShrink: 0 }}>
|
||||
{t('smartFolders.status.done', 'Done')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap={2} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(folder); }}
|
||||
aria-label={t('smartFolders.home.editFolder', 'Edit folder')}
|
||||
>
|
||||
<EditIcon style={{ fontSize: 14 }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={(e) => { e.stopPropagation(); onOpen(folder.id); }}
|
||||
aria-label={t('smartFolders.home.openFolder', 'Open folder')}
|
||||
>
|
||||
<OpenInNewIcon style={{ fontSize: 14 }} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Description */}
|
||||
{folder.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{folder.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Operation pills */}
|
||||
{automation && automation.operations.length > 0 && (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{automation.operations.map((op, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
style={{ borderColor: folder.accentColor, color: folder.accentColor }}
|
||||
>
|
||||
{humaniseOp(op.operation)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{/* Drop overlay */}
|
||||
{isDragOver && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: `${folder.accentColor}20`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<UploadFileIcon style={{ fontSize: 18, color: folder.accentColor }} />
|
||||
<Text fw={700} size="sm" style={{ color: folder.accentColor }}>
|
||||
{t('smartFolders.home.dropHere', 'Drop to process')}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function SmartFolderHomePage() {
|
||||
const { t } = useTranslation();
|
||||
const { folders, loading, deleteFolder, refreshFolders } = useSmartFolders();
|
||||
const statuses = useFolderRunStatuses(folders);
|
||||
const { toolRegistry, setCustomWorkbenchViewData } = useToolWorkflow();
|
||||
const { actions } = useNavigationActions();
|
||||
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editFolder, setEditFolder] = useState<SmartFolder | null>(null);
|
||||
const [editAutomation, setEditAutomation] = useState<AutomationConfig | null>(null);
|
||||
const [processingFolderIds, setProcessingFolderIds] = useState<Set<string>>(new Set());
|
||||
const [deleteTarget, setDeleteTarget] = useState<SmartFolder | null>(null);
|
||||
|
||||
const navigateToFolder = useCallback((folderId: string) => {
|
||||
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId });
|
||||
actions.setWorkbench(SMART_FOLDER_WORKBENCH_ID);
|
||||
}, [setCustomWorkbenchViewData, actions]);
|
||||
|
||||
const handleEdit = useCallback(async (folder: SmartFolder) => {
|
||||
setEditFolder(folder);
|
||||
const automation = await automationStorage.getAutomation(folder.automationId);
|
||||
setEditAutomation(automation);
|
||||
setCreateModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleModalClose = () => {
|
||||
setCreateModalOpen(false);
|
||||
setEditFolder(null);
|
||||
setEditAutomation(null);
|
||||
};
|
||||
|
||||
const processFiles = useCallback(async (folder: SmartFolder, files: File[]) => {
|
||||
const pdfs = files.filter(f => f.name.toLowerCase().endsWith('.pdf'));
|
||||
if (pdfs.length === 0) return;
|
||||
|
||||
setProcessingFolderIds(prev => new Set([...prev, folder.id]));
|
||||
try {
|
||||
const automation: AutomationConfig | null = await automationStorage.getAutomation(folder.automationId);
|
||||
if (!automation) return;
|
||||
|
||||
for (const file of pdfs) {
|
||||
const inputFileId = `input-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
await folderStorage.addFileToFolder(folder.id, inputFileId, { status: 'processing' });
|
||||
try {
|
||||
const resultFiles = await executeAutomationSequence(automation, [file], toolRegistry);
|
||||
const existingRuns = await folderRunStateStorage.getFolderRunState(folder.id);
|
||||
const newRuns: SmartFolderRunEntry[] = [...existingRuns];
|
||||
for (const resultFile of resultFiles) {
|
||||
const outputId = `output-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
await folderStorage.storeOutputFile(folder.id, outputId, resultFile, resultFile.name);
|
||||
newRuns.push({ inputFileId, displayFileId: outputId, status: 'processed' });
|
||||
}
|
||||
await folderStorage.updateFileMetadata(folder.id, inputFileId, {
|
||||
status: 'processed',
|
||||
processedAt: new Date(),
|
||||
});
|
||||
await folderRunStateStorage.setFolderRunState(folder.id, newRuns);
|
||||
} catch {
|
||||
await folderStorage.updateFileMetadata(folder.id, inputFileId, { status: 'error' });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setProcessingFolderIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(folder.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [toolRegistry]);
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
await deleteFolder(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<Box
|
||||
px="md"
|
||||
py="sm"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
backgroundColor: 'var(--bg-toolbar)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm">{t('smartFolders.home.title', 'Watch Folders')}</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<AddIcon style={{ fontSize: 14 }} />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
{t('smartFolders.newFolder', 'New folder')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* Folder list */}
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Box p="sm">
|
||||
{loading ? (
|
||||
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem' }}>
|
||||
<Loader size="sm" />
|
||||
</Box>
|
||||
) : folders.length === 0 ? (
|
||||
<Box style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '3rem', gap: '1rem' }}>
|
||||
<Text c="dimmed" ta="center" size="sm">
|
||||
{t('smartFolders.home.empty', 'No watch folders yet')}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<AddIcon style={{ fontSize: 14 }} />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
{t('smartFolders.home.create', 'Create your first folder')}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{folders.map((folder) => {
|
||||
const status = statuses[folder.id] ?? 'idle';
|
||||
const isProcessing = processingFolderIds.has(folder.id) || status === 'processing';
|
||||
return (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
status={status}
|
||||
isProcessing={isProcessing}
|
||||
onEdit={handleEdit}
|
||||
onOpen={navigateToFolder}
|
||||
onDropFiles={processFiles}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
|
||||
<SmartFolderManagementModal
|
||||
opened={createModalOpen}
|
||||
editFolder={editFolder}
|
||||
existingAutomation={editAutomation}
|
||||
onClose={handleModalClose}
|
||||
onSaved={refreshFolders}
|
||||
/>
|
||||
<DeleteFolderConfirmModal
|
||||
opened={!!deleteTarget}
|
||||
folder={deleteTarget}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SmartFolder } from '@app/types/smartFolders';
|
||||
import { AutomationConfig, AutomationMode } from '@app/types/automation';
|
||||
import IconSelector from '@app/components/tools/automate/IconSelector';
|
||||
import { IconPicker as IconSelector } from '@app/components/smartFolders/IconPicker';
|
||||
import AutomationCreation from '@app/components/tools/automate/AutomationCreation';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { smartFolderStorage } from '@app/services/smartFolderStorage';
|
||||
@@ -119,8 +119,8 @@ export function SmartFolderManagementModal({
|
||||
};
|
||||
|
||||
const title = isEditMode
|
||||
? t('smartFolders.modal.editTitle', 'Edit Smart Folder')
|
||||
: t('smartFolders.modal.createTitle', 'New Smart Folder');
|
||||
? t('smartFolders.modal.editTitle', 'Edit Watch Folder')
|
||||
: t('smartFolders.modal.createTitle', 'New Watch Folder');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -136,7 +136,7 @@ export function SmartFolderManagementModal({
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput
|
||||
label={t('smartFolders.modal.name', 'Folder name')}
|
||||
placeholder={t('smartFolders.modal.namePlaceholder', 'My Smart Folder')}
|
||||
placeholder={t('smartFolders.modal.namePlaceholder', 'My Watch Folder')}
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.currentTarget.value); setNameError(''); }}
|
||||
error={nameError}
|
||||
@@ -197,7 +197,7 @@ export function SmartFolderManagementModal({
|
||||
onComplete={handleAutomationComplete}
|
||||
toolRegistry={toolRegistry}
|
||||
hideMetadata
|
||||
nameOverride={name.trim() || 'Smart Folder Automation'}
|
||||
nameOverride={name.trim() || 'Watch Folder Automation'}
|
||||
saveTriggerRef={automationSaveTrigger}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Button, Text, Stack, ActionIcon, Group, Loader } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { Box, Button, Text, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import { useSmartFolders } from '@app/hooks/useSmartFolders';
|
||||
import { useFolderRunStatuses } from '@app/hooks/useFolderRunStatuses';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { SmartFolderManagementModal } from '@app/components/smartFolders/SmartFolderManagementModal';
|
||||
import { DeleteFolderConfirmModal } from '@app/components/smartFolders/DeleteFolderConfirmModal';
|
||||
import { SmartFolderWorkbenchView } from '@app/components/smartFolders/SmartFolderWorkbenchView';
|
||||
import { SmartFolderCard } from '@app/components/smartFolders/SmartFolderCard';
|
||||
import { SmartFolder } from '@app/types/smartFolders';
|
||||
import { AutomationConfig } from '@app/types/automation';
|
||||
import { automationStorage } from '@app/services/automationStorage';
|
||||
import { seedDefaultFolders } from '@app/data/smartFolderPresets';
|
||||
import { iconMap } from '@app/components/tools/automate/iconMap';
|
||||
|
||||
const SMART_FOLDER_VIEW_ID = 'smartFolder';
|
||||
const SMART_FOLDER_WORKBENCH_ID = 'custom:smartFolder' as const;
|
||||
import { SMART_FOLDER_VIEW_ID, SMART_FOLDER_WORKBENCH_ID } from '@app/components/smartFolders/SmartFoldersRegistration';
|
||||
|
||||
export function SmartFolderSection() {
|
||||
const { t } = useTranslation();
|
||||
@@ -28,28 +21,13 @@ export function SmartFolderSection() {
|
||||
const [editAutomation, setEditAutomation] = useState<AutomationConfig | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SmartFolder | null>(null);
|
||||
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
|
||||
const { folders, loading, deleteFolder, refreshFolders } = useSmartFolders();
|
||||
const statuses = useFolderRunStatuses(folders);
|
||||
|
||||
const { registerCustomWorkbenchView, unregisterCustomWorkbenchView, setCustomWorkbenchViewData } = useToolWorkflow();
|
||||
const { setCustomWorkbenchViewData } = useToolWorkflow();
|
||||
const { actions } = useNavigationActions();
|
||||
|
||||
useEffect(() => {
|
||||
seedDefaultFolders();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
registerCustomWorkbenchView({
|
||||
id: SMART_FOLDER_VIEW_ID,
|
||||
workbenchId: SMART_FOLDER_WORKBENCH_ID,
|
||||
label: t('smartFolders.title', 'Smart Folders'),
|
||||
component: SmartFolderWorkbenchView,
|
||||
});
|
||||
return () => unregisterCustomWorkbenchView(SMART_FOLDER_VIEW_ID);
|
||||
}, [registerCustomWorkbenchView, unregisterCustomWorkbenchView, t]);
|
||||
|
||||
const handleFolderClick = (folderId: string) => {
|
||||
setActiveFolderId(folderId);
|
||||
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId });
|
||||
@@ -96,89 +74,42 @@ export function SmartFolderSection() {
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Section header — matches .tool-subcategory-row style */}
|
||||
<Box px="sm" pt="xs" pb="2px" style={{ flexShrink: 0 }}>
|
||||
<Box className="tool-subcategory-row">
|
||||
<Text className="tool-subcategory-row-title">
|
||||
{t('smartFolders.title', 'Smart Folders')}
|
||||
<Text
|
||||
className="tool-subcategory-row-title"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId: null });
|
||||
actions.setWorkbench(SMART_FOLDER_WORKBENCH_ID);
|
||||
}}
|
||||
>
|
||||
{t('smartFolders.title', 'Watch Folders')}
|
||||
</Text>
|
||||
<Box className="tool-subcategory-row-rule" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Scrollable folder list */}
|
||||
<Box className="tool-picker-scrollable" style={{ flex: 1, minHeight: 0 }}>
|
||||
<Stack gap={0} pb="xs">
|
||||
{!loading && folders.length === 0 && (
|
||||
<Text size="xs" c="dimmed" ta="center" py="sm" px="sm">
|
||||
{t('smartFolders.noFolders', 'No smart folders yet')}
|
||||
{t('smartFolders.noFolders', 'No watch folders yet')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{folders.map((folder) => {
|
||||
const IconComponent = iconMap[folder.icon as keyof typeof iconMap] || iconMap.FolderIcon;
|
||||
const status = statuses[folder.id] ?? 'idle';
|
||||
const isActive = activeFolderId === folder.id;
|
||||
const isHovered = hoveredId === folder.id;
|
||||
{folders.map((folder) => (
|
||||
<SmartFolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
isActive={activeFolderId === folder.id}
|
||||
status={statuses[folder.id] ?? 'idle'}
|
||||
onSelect={() => handleFolderClick(folder.id)}
|
||||
onEdit={(e) => handleEditFolder(e, folder)}
|
||||
onDelete={(e) => handleDeleteClick(e, folder)}
|
||||
/>
|
||||
))}
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={folder.id}
|
||||
className="tool-button-container"
|
||||
onMouseEnter={() => setHoveredId(folder.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
>
|
||||
<Button
|
||||
variant={isActive ? 'light' : 'subtle'}
|
||||
className="tool-button"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
px="sm"
|
||||
leftSection={
|
||||
<Box
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: `${folder.accentColor}22`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IconComponent style={{ fontSize: 11, color: folder.accentColor }} />
|
||||
</Box>
|
||||
}
|
||||
rightSection={
|
||||
isHovered ? (
|
||||
<Group gap={2} onClick={(e) => e.stopPropagation()}>
|
||||
<ActionIcon size="xs" variant="subtle" onClick={(e) => handleEditFolder(e, folder)}>
|
||||
<EditIcon style={{ fontSize: 11 }} />
|
||||
</ActionIcon>
|
||||
{!folder.isDefault && (
|
||||
<ActionIcon size="xs" variant="subtle" color="red" onClick={(e) => handleDeleteClick(e, folder)}>
|
||||
<DeleteIcon style={{ fontSize: 11 }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
) : status === 'processing' ? (
|
||||
<Loader size={10} color={folder.accentColor} />
|
||||
) : status === 'done' ? (
|
||||
<CheckCircleIcon style={{ fontSize: 12, color: 'var(--mantine-color-teal-6)' }} />
|
||||
) : null
|
||||
}
|
||||
onClick={() => handleFolderClick(folder.id)}
|
||||
>
|
||||
<Text size="sm" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{folder.name}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* New folder button */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
className="tool-button"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Box, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FileManagerProvider } from '@app/contexts/FileManagerContext';
|
||||
import FileListArea from '@app/components/fileManager/FileListArea';
|
||||
import { useFileManager } from '@app/hooks/useFileManager';
|
||||
import { useAllFiles } from '@app/contexts/FileContext';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { SMART_FOLDER_VIEW_ID } from '@app/components/smartFolders/SmartFoldersRegistration';
|
||||
|
||||
export function SmartFolderSidebarPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
|
||||
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
|
||||
const { fileIds: activeFileIds } = useAllFiles();
|
||||
const { customWorkbenchViews, setCustomWorkbenchViewData } = useToolWorkflow();
|
||||
|
||||
const smartFolderView = customWorkbenchViews.find(v => v.id === SMART_FOLDER_VIEW_ID);
|
||||
const folderId = smartFolderView?.data?.folderId ?? null;
|
||||
|
||||
const refreshRecentFiles = useCallback(async () => {
|
||||
const files = await loadRecentFiles();
|
||||
setRecentFiles(files);
|
||||
}, [loadRecentFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshRecentFiles();
|
||||
}, [refreshRecentFiles]);
|
||||
|
||||
const handleRemoveFileByIndex = useCallback(async (index: number) => {
|
||||
await handleRemoveFile(index, recentFiles, setRecentFiles);
|
||||
}, [handleRemoveFile, recentFiles]);
|
||||
|
||||
// Double-clicking a file sends it to the currently open folder (no-op on home page)
|
||||
const handleFilesSelected = useCallback((files: StirlingFileStub[]) => {
|
||||
if (files.length === 0 || !folderId) return;
|
||||
files.forEach((file, i) => {
|
||||
setTimeout(() => {
|
||||
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId, pendingFileId: file.id });
|
||||
}, i * 50);
|
||||
});
|
||||
}, [folderId, setCustomWorkbenchViewData]);
|
||||
|
||||
return (
|
||||
<Box style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<Box px="sm" pt="xs" pb="xs" style={{ flexShrink: 0, borderBottom: '1px solid var(--border-subtle)' }}>
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
{t('smartFolders.sidebarFiles', 'Your Files')}
|
||||
</Text>
|
||||
</Box>
|
||||
<FileManagerProvider
|
||||
recentFiles={recentFiles}
|
||||
onRecentFilesSelected={handleFilesSelected}
|
||||
onNewFilesSelect={() => {}}
|
||||
onClose={() => {}}
|
||||
isFileSupported={(name) => name.toLowerCase().endsWith('.pdf')}
|
||||
isOpen={true}
|
||||
onFileRemove={handleRemoveFileByIndex}
|
||||
modalHeight="100%"
|
||||
refreshRecentFiles={refreshRecentFiles}
|
||||
isLoading={loading}
|
||||
activeFileIds={activeFileIds}
|
||||
>
|
||||
<FileListArea scrollAreaHeight="100%" scrollAreaStyle={{ flex: 1, minHeight: 0 }} />
|
||||
</FileManagerProvider>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -18,15 +18,19 @@ import { useSmartFolders } from '@app/hooks/useSmartFolders';
|
||||
import { useFolderData } from '@app/hooks/useFolderData';
|
||||
import { useFolderRunState } from '@app/hooks/useFolderRunState';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { SMART_FOLDER_VIEW_ID } from '@app/components/smartFolders/SmartFoldersRegistration';
|
||||
import { automationStorage } from '@app/services/automationStorage';
|
||||
import { folderStorage } from '@app/services/folderStorage';
|
||||
import { executeAutomationSequence } from '@app/utils/automationExecutor';
|
||||
import { SmartFolderRunEntry } from '@app/types/smartFolders';
|
||||
import { AutomationConfig } from '@app/types/automation';
|
||||
import { iconMap } from '@app/components/tools/automate/iconMap';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
import { FileId } from '@app/types/fileContext';
|
||||
import { SmartFolderHomePage } from '@app/components/smartFolders/SmartFolderHomePage';
|
||||
|
||||
interface SmartFolderWorkbenchViewProps {
|
||||
data: { folderId: string };
|
||||
data: { folderId: string | null; pendingFileId?: string };
|
||||
}
|
||||
|
||||
interface StepProgress {
|
||||
@@ -41,7 +45,7 @@ type FilterTab = 'all' | 'pending' | 'processing' | 'processed';
|
||||
export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps) {
|
||||
const { folderId } = data;
|
||||
const { t } = useTranslation();
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
const { toolRegistry, setCustomWorkbenchViewData } = useToolWorkflow();
|
||||
const { folders } = useSmartFolders();
|
||||
const folder = folders.find(f => f.id === folderId);
|
||||
|
||||
@@ -62,6 +66,7 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
|
||||
const [filterTab, setFilterTab] = useState<FilterTab>('all');
|
||||
const [outputFiles, setOutputFiles] = useState<{ fileId: string; name: string; blob: Blob }[]>([]);
|
||||
const processingRef = useRef<Set<string>>(new Set());
|
||||
const handledPendingRef = useRef<string | null>(null);
|
||||
|
||||
// Load output files
|
||||
useEffect(() => {
|
||||
@@ -154,6 +159,18 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
|
||||
[addFile, runAutomation]
|
||||
);
|
||||
|
||||
// Auto-process a file passed via workbench data (e.g. from sidebar or "Add to Smart Folder")
|
||||
useEffect(() => {
|
||||
const { pendingFileId } = data;
|
||||
if (!pendingFileId || handledPendingRef.current === pendingFileId) return;
|
||||
handledPendingRef.current = pendingFileId;
|
||||
// Clear pendingFileId from workbench data so re-mounting doesn't replay it
|
||||
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId });
|
||||
fileStorage.getStirlingFile(pendingFileId as FileId).then((stirlingFile) => {
|
||||
if (stirlingFile) handleFiles([stirlingFile]);
|
||||
});
|
||||
}, [data, folderId, handleFiles, setCustomWorkbenchViewData]);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -195,6 +212,11 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
|
||||
return fileIds;
|
||||
})();
|
||||
|
||||
// Home page: no specific folder selected
|
||||
if (!folderId) {
|
||||
return <SmartFolderHomePage />;
|
||||
}
|
||||
|
||||
const FolderIcon = folder ? (iconMap[folder.icon as keyof typeof iconMap] || iconMap.SettingsIcon) : iconMap.SettingsIcon;
|
||||
|
||||
if (!folder) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { SmartFolderWorkbenchView } from '@app/components/smartFolders/SmartFolderWorkbenchView';
|
||||
import { seedDefaultFolders } from '@app/data/smartFolderPresets';
|
||||
|
||||
export const SMART_FOLDER_VIEW_ID = 'smartFolder';
|
||||
export const SMART_FOLDER_WORKBENCH_ID = 'custom:smartFolder' as const;
|
||||
|
||||
export default function SmartFoldersRegistration() {
|
||||
const { registerCustomWorkbenchView, unregisterCustomWorkbenchView, clearCustomWorkbenchViewData } = useToolWorkflow();
|
||||
|
||||
// Keep refs to latest cleanup callbacks so the registration effect doesn't
|
||||
// re-run (and tear down) when unregisterCustomWorkbenchView changes identity
|
||||
// due to NavigationContext re-renders.
|
||||
const unregisterRef = useRef(unregisterCustomWorkbenchView);
|
||||
const clearRef = useRef(clearCustomWorkbenchViewData);
|
||||
useEffect(() => { unregisterRef.current = unregisterCustomWorkbenchView; });
|
||||
useEffect(() => { clearRef.current = clearCustomWorkbenchViewData; });
|
||||
|
||||
useEffect(() => {
|
||||
seedDefaultFolders();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
registerCustomWorkbenchView({
|
||||
id: SMART_FOLDER_VIEW_ID,
|
||||
workbenchId: SMART_FOLDER_WORKBENCH_ID,
|
||||
label: 'Watch Folders',
|
||||
component: SmartFolderWorkbenchView,
|
||||
});
|
||||
return () => {
|
||||
clearRef.current(SMART_FOLDER_VIEW_ID);
|
||||
unregisterRef.current(SMART_FOLDER_VIEW_ID);
|
||||
};
|
||||
}, [registerCustomWorkbenchView]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { useRightRail } from '@app/contexts/RightRailContext';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import '@app/components/tools/ToolPanel.css';
|
||||
import { SmartFolderSection } from '@app/components/smartFolders/SmartFolderSection';
|
||||
import { SmartFolderSidebarPanel } from '@app/components/smartFolders/SmartFolderSidebarPanel';
|
||||
import { useSmartFolderSidebar } from '@app/hooks/useSmartFolderSidebar';
|
||||
|
||||
// No props needed - component uses context
|
||||
|
||||
@@ -48,6 +50,8 @@ export default function ToolPanel() {
|
||||
const { setAllRightRailButtonsDisabled } = useRightRail();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
|
||||
const { isActive: isSmartFolderSidebar } = useSmartFolderSidebar();
|
||||
|
||||
const isFullscreenMode = toolPanelMode === 'fullscreen';
|
||||
const toolPickerVisible = !readerMode;
|
||||
const fullscreenExpanded = isFullscreenMode && leftPanelView === 'toolPicker' && !isMobile && toolPickerVisible;
|
||||
@@ -124,84 +128,94 @@ export default function ToolPanel() {
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="tool-panel__search-row"
|
||||
style={{
|
||||
backgroundColor: 'var(--tool-panel-search-bg)',
|
||||
borderBottom: '1px solid var(--tool-panel-search-border-bottom)'
|
||||
}}
|
||||
>
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
/>
|
||||
{!isMobile && leftPanelView === 'toolPicker' && (
|
||||
<Tooltip
|
||||
content={toggleLabel}
|
||||
position="bottom"
|
||||
arrow={true}
|
||||
openOnFocus={false}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="xl"
|
||||
style={{ color: 'var(--right-rail-icon)' }}
|
||||
onClick={handleModeToggle}
|
||||
aria-label={toggleLabel}
|
||||
className="tool-panel__mode-toggle"
|
||||
{!isSmartFolderSidebar && (
|
||||
<div
|
||||
className="tool-panel__search-row"
|
||||
style={{
|
||||
backgroundColor: 'var(--tool-panel-search-bg)',
|
||||
borderBottom: '1px solid var(--tool-panel-search-border-bottom)'
|
||||
}}
|
||||
>
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
/>
|
||||
{!isMobile && leftPanelView === 'toolPicker' && (
|
||||
<Tooltip
|
||||
content={toggleLabel}
|
||||
position="bottom"
|
||||
arrow={true}
|
||||
openOnFocus={false}
|
||||
>
|
||||
<DoubleArrowIcon
|
||||
fontSize="small"
|
||||
style={{ transform: isRTL ? 'scaleX(-1)' : undefined }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
{searchQuery.trim().length > 0 ? (
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
<SearchResults
|
||||
filteredTools={filteredTools}
|
||||
onSelect={(id) => handleToolSelect(id as ToolId)}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
) : leftPanelView === 'toolPicker' ? (
|
||||
<div className="flex-1 flex flex-col overflow-auto">
|
||||
<ToolPicker
|
||||
selectedToolKey={selectedToolKey}
|
||||
onSelect={(id) => handleToolSelect(id as ToolId)}
|
||||
filteredTools={filteredTools}
|
||||
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ScrollArea h="100%">
|
||||
{selectedToolKey ? (
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
onPreviewFile={setPreviewFile}
|
||||
/>
|
||||
) : (
|
||||
<div className="tool-panel__placeholder">
|
||||
{t('toolPanel.placeholder', 'Choose a tool to get started')}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="xl"
|
||||
style={{ color: 'var(--right-rail-icon)' }}
|
||||
onClick={handleModeToggle}
|
||||
aria-label={toggleLabel}
|
||||
className="tool-panel__mode-toggle"
|
||||
>
|
||||
<DoubleArrowIcon
|
||||
fontSize="small"
|
||||
style={{ transform: isRTL ? 'scaleX(-1)' : undefined }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: '0 0 50%', minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<SmartFolderSection />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
{isSmartFolderSidebar ? (
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<SmartFolderSidebarPanel />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
{searchQuery.trim().length > 0 ? (
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
<SearchResults
|
||||
filteredTools={filteredTools}
|
||||
onSelect={(id) => handleToolSelect(id as ToolId)}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
) : leftPanelView === 'toolPicker' ? (
|
||||
<div className="flex-1 flex flex-col overflow-auto">
|
||||
<ToolPicker
|
||||
selectedToolKey={selectedToolKey}
|
||||
onSelect={(id) => handleToolSelect(id as ToolId)}
|
||||
filteredTools={filteredTools}
|
||||
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ScrollArea h="100%">
|
||||
{selectedToolKey ? (
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
onPreviewFile={setPreviewFile}
|
||||
/>
|
||||
) : (
|
||||
<div className="tool-panel__placeholder">
|
||||
{t('toolPanel.placeholder', 'Choose a tool to get started')}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: '0 0 50%', minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<SmartFolderSection />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import { useFolderData } from '@app/hooks/useFolderData';
|
||||
import { FolderFileMetadata, FolderRecord } from '@app/types/smartFolders';
|
||||
|
||||
interface FolderFileContextValue {
|
||||
activeFolderId: string | null;
|
||||
setActiveFolderId: (id: string | null) => void;
|
||||
folderRecord: FolderRecord | null;
|
||||
fileIds: string[];
|
||||
pendingFileIds: string[];
|
||||
processingFileIds: string[];
|
||||
processedFileIds: string[];
|
||||
addFile: (fileId: string, metadata?: Partial<FolderFileMetadata>) => Promise<void>;
|
||||
removeFile: (fileId: string) => Promise<void>;
|
||||
updateFileMetadata: (fileId: string, updates: Partial<FolderFileMetadata>) => Promise<void>;
|
||||
clearFolder: () => Promise<void>;
|
||||
getFileMetadata: (fileId: string) => FolderFileMetadata | null;
|
||||
isFileProcessed: (fileId: string) => boolean;
|
||||
isFileProcessing: (fileId: string) => boolean;
|
||||
}
|
||||
|
||||
const FolderFileContext = createContext<FolderFileContextValue | null>(null);
|
||||
|
||||
interface FolderFileContextProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Inner component so useFolderData is only called once activeFolderId is set
|
||||
function FolderFileContextInner({
|
||||
activeFolderId,
|
||||
setActiveFolderId,
|
||||
children,
|
||||
}: {
|
||||
activeFolderId: string | null;
|
||||
setActiveFolderId: (id: string | null) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const folderData = useFolderData(activeFolderId ?? '');
|
||||
return (
|
||||
<FolderFileContext.Provider value={{ activeFolderId, setActiveFolderId, ...folderData }}>
|
||||
{children}
|
||||
</FolderFileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderFileContextProvider({ children }: FolderFileContextProviderProps) {
|
||||
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
|
||||
return (
|
||||
<FolderFileContextInner activeFolderId={activeFolderId} setActiveFolderId={setActiveFolderId}>
|
||||
{children}
|
||||
</FolderFileContextInner>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFolderFileContext(): FolderFileContextValue {
|
||||
const ctx = useContext(FolderFileContext);
|
||||
if (!ctx) throw new Error('useFolderFileContext must be used within FolderFileContextProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Read-only hook that returns all smart folders, kept in sync with storage changes.
|
||||
* Use this when you only need to read the folder list without CRUD operations.
|
||||
* For full CRUD, use useSmartFolders instead.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { SmartFolder } from '@app/types/smartFolders';
|
||||
import { smartFolderStorage, SMART_FOLDER_STORAGE_CHANGE_EVENT } from '@app/services/smartFolderStorage';
|
||||
|
||||
export function useAllSmartFolders(): SmartFolder[] {
|
||||
const [folders, setFolders] = useState<SmartFolder[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
setFolders(await smartFolderStorage.getAllFolders());
|
||||
} catch (err) {
|
||||
console.error('Failed to load smart folders:', err);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
window.addEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, load);
|
||||
return () => window.removeEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, load);
|
||||
}, []);
|
||||
|
||||
return folders;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Returns a map of fileId → folderId for all files currently in any smart folder.
|
||||
* Used to verify folder-scoped file isolation — a file in this map is owned by a folder
|
||||
* and must not appear in the global file context.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { folderStorage } from '@app/services/folderStorage';
|
||||
import { useAllSmartFolders } from '@app/hooks/useAllSmartFolders';
|
||||
|
||||
export function useFolderMembership(): Map<string, string> {
|
||||
const folders = useAllSmartFolders();
|
||||
const [membership, setMembership] = useState<Map<string, string>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (folders.length === 0) {
|
||||
setMembership(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
const map = new Map<string, string>();
|
||||
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);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore individual folder failures
|
||||
}
|
||||
}
|
||||
setMembership(map);
|
||||
};
|
||||
|
||||
load();
|
||||
}, [folders]);
|
||||
|
||||
return membership;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useNavigationState } from '@app/contexts/NavigationContext';
|
||||
|
||||
const SMART_FOLDER_WORKBENCH_ID = 'custom:smartFolder';
|
||||
|
||||
export function useSmartFolderSidebar() {
|
||||
const { workbench } = useNavigationState();
|
||||
return { isActive: workbench === SMART_FOLDER_WORKBENCH_ID };
|
||||
}
|
||||
@@ -11,7 +11,7 @@ interface RunStateRecord {
|
||||
}
|
||||
|
||||
class FolderRunStateStorage {
|
||||
private dbName = 'StirlingPDF_FolderRunState';
|
||||
private dbName = 'stirling-pdf-folder-run-state';
|
||||
private dbVersion = 1;
|
||||
private storeName = 'runStates';
|
||||
private db: IDBDatabase | null = null;
|
||||
|
||||
@@ -15,7 +15,7 @@ interface OutputFileRecord {
|
||||
}
|
||||
|
||||
class FolderStorage {
|
||||
private dbName = 'StirlingPDF_FolderFiles';
|
||||
private dbName = 'stirling-pdf-folder-files';
|
||||
private dbVersion = 1;
|
||||
private recordsStore = 'folderRecords';
|
||||
private outputStore = 'folderOutputFiles';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SmartFolder } from '@app/types/smartFolders';
|
||||
const STORAGE_CHANGE_EVENT = 'smart-folder-storage-changed';
|
||||
|
||||
class SmartFolderStorage {
|
||||
private dbName = 'StirlingPDF_SmartFolders';
|
||||
private dbName = 'stirling-pdf-smart-folders';
|
||||
private dbVersion = 1;
|
||||
private storeName = 'smartFolders';
|
||||
private db: IDBDatabase | null = null;
|
||||
|
||||
@@ -191,6 +191,8 @@
|
||||
--icon-files-color: #0A8BFF;
|
||||
--icon-activity-bg: #D3E7F7;
|
||||
--icon-activity-color: #0A8BFF;
|
||||
--icon-watchFolders-bg: #F59E0B;
|
||||
--icon-watchFolders-color: #FFFFFF;
|
||||
--icon-config-bg: #9CA3AF;
|
||||
--icon-config-color: #FFFFFF;
|
||||
|
||||
@@ -475,6 +477,8 @@
|
||||
--icon-files-color: #EAEAEA;
|
||||
--icon-activity-bg: #4B525A;
|
||||
--icon-activity-color: #EAEAEA;
|
||||
--icon-watchFolders-bg: #4B525A;
|
||||
--icon-watchFolders-color: #EAEAEA;
|
||||
--icon-config-bg: #4B525A;
|
||||
--icon-config-color: #EAEAEA;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import AuthCallback from "@app/routes/AuthCallback";
|
||||
import InviteAccept from "@app/routes/InviteAccept";
|
||||
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||
|
||||
import SmartFoldersRegistration from "@app/components/smartFolders/SmartFoldersRegistration";
|
||||
// Import global styles
|
||||
import "@app/styles/tailwind.css";
|
||||
import "@app/styles/cookieconsent.css";
|
||||
@@ -64,6 +64,7 @@ export default function App() {
|
||||
<Route path="/*" element={<Landing />} />
|
||||
</Routes>
|
||||
<Onboarding />
|
||||
<SmartFoldersRegistration />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user