Ui overhaul step 1
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Box, Text, Stack, Group, ActionIcon, Badge, Button, Loader, ScrollArea } from '@mantine/core';
|
||||
import { Box, Text, Stack, Group, ActionIcon, 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 DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import FolderPlusIcon from '@mui/icons-material/CreateNewFolder';
|
||||
import { useSmartFolders } from '@app/hooks/useSmartFolders';
|
||||
import { useFolderRunStatuses } from '@app/hooks/useFolderRunStatuses';
|
||||
import { SmartFolder, SmartFolderRunEntry } from '@app/types/smartFolders';
|
||||
@@ -18,13 +22,25 @@ import { SmartFolderManagementModal } from '@app/components/smartFolders/SmartFo
|
||||
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';
|
||||
import {
|
||||
SMART_FOLDER_VIEW_ID,
|
||||
SMART_FOLDER_WORKBENCH_ID,
|
||||
} from '@app/components/smartFolders/SmartFoldersRegistration';
|
||||
|
||||
const KEYFRAMES = `
|
||||
@keyframes wf-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.35; transform: scale(0.7); }
|
||||
}
|
||||
`;
|
||||
|
||||
// Humanise an operation key like "compress-pdf" → "Compress PDF"
|
||||
function humaniseOp(op: string): string {
|
||||
return op
|
||||
.replace(/-pdf$|-pages$|-documents?$/i, '')
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
.replace(/\bocr\b/gi, 'OCR')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
interface FolderCardProps {
|
||||
@@ -32,15 +48,25 @@ interface FolderCardProps {
|
||||
status: 'idle' | 'processing' | 'done';
|
||||
isProcessing: boolean;
|
||||
onEdit: (folder: SmartFolder) => void;
|
||||
onDelete: (folder: SmartFolder) => void;
|
||||
onOpen: (folderId: string) => void;
|
||||
onDropFiles: (folder: SmartFolder, files: File[]) => void;
|
||||
}
|
||||
|
||||
function FolderCard({ folder, status, isProcessing, onEdit, onOpen, onDropFiles }: FolderCardProps) {
|
||||
function FolderCard({
|
||||
folder,
|
||||
status,
|
||||
isProcessing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onOpen,
|
||||
onDropFiles,
|
||||
}: FolderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [automation, setAutomation] = useState<AutomationConfig | null>(null);
|
||||
const [fileCount, setFileCount] = useState(0);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
automationStorage.getAutomation(folder.automationId).then(setAutomation);
|
||||
@@ -58,11 +84,13 @@ function FolderCard({ folder, status, isProcessing, onEdit, onOpen, onDropFiles
|
||||
}, [folder.id, folder.automationId]);
|
||||
|
||||
const FolderIcon = iconMap[folder.icon as keyof typeof iconMap] ?? iconMap.FolderIcon;
|
||||
const isActive = isProcessing || status === 'processing';
|
||||
const isDone = status === 'done' && !isActive;
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
const statusDotColor = isActive ? '#3b82f6' : isDone ? '#22c55e' : '#6b7280';
|
||||
const statusDotPulse = isActive;
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true); };
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragOver(false);
|
||||
};
|
||||
@@ -72,135 +100,361 @@ function FolderCard({ folder, status, isProcessing, onEdit, onOpen, onDropFiles
|
||||
onDropFiles(folder, Array.from(e.dataTransfer.files));
|
||||
};
|
||||
|
||||
const ops = automation?.operations ?? [];
|
||||
|
||||
const cardBorderColor = isDragOver
|
||||
? folder.accentColor
|
||||
: isHovered
|
||||
? folder.accentColor
|
||||
: 'var(--border-subtle)';
|
||||
|
||||
const cardBoxShadow = isDragOver || isHovered
|
||||
? `0 0.25rem 0.75rem ${folder.accentColor}15`
|
||||
: 'none';
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
border: `2px solid ${isDragOver ? folder.accentColor : 'var(--border-subtle)'}`,
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
padding: '0.875rem 1rem',
|
||||
border: `0.0625rem solid ${cardBorderColor}`,
|
||||
backgroundColor: isDragOver
|
||||
? `${folder.accentColor}15`
|
||||
? `${folder.accentColor}08`
|
||||
: 'var(--bg-surface, var(--mantine-color-default))',
|
||||
transition: 'border-color 0.15s, background-color 0.15s',
|
||||
transition: 'border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease',
|
||||
boxShadow: cardBoxShadow,
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
cursor: 'default',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => onOpen(folder.id)}
|
||||
>
|
||||
<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>
|
||||
<Box style={{ padding: '1.25rem' }}>
|
||||
<Group align="flex-start" wrap="nowrap" gap="md">
|
||||
|
||||
{/* 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>
|
||||
{/* Icon with status dot */}
|
||||
<Box style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: '3rem',
|
||||
height: '3rem',
|
||||
borderRadius: '0.625rem',
|
||||
backgroundColor: `${folder.accentColor}18`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<FolderIcon style={{ fontSize: '1.5rem', color: folder.accentColor }} />
|
||||
</Box>
|
||||
{/* Status dot overlay */}
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-0.1875rem',
|
||||
right: '-0.1875rem',
|
||||
width: '0.875rem',
|
||||
height: '0.875rem',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--bg-surface, var(--mantine-color-default))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: '0.5rem',
|
||||
height: '0.5rem',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: statusDotColor,
|
||||
animation: statusDotPulse ? 'wf-pulse 1.4s ease-in-out infinite' : 'none',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<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 }}
|
||||
{/* Main content */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* Name + status pill + actions */}
|
||||
<Group justify="space-between" align="center" wrap="nowrap" mb="xs">
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
style={{ letterSpacing: '-0.01em', lineHeight: 1.3 }}
|
||||
lineClamp={1}
|
||||
>
|
||||
{humaniseOp(op.operation)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
{folder.name}
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
padding: '0.125rem 0.5rem',
|
||||
borderRadius: '1rem',
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 500,
|
||||
backgroundColor: isActive
|
||||
? 'rgba(59, 130, 246, 0.12)'
|
||||
: isDone
|
||||
? 'rgba(34, 197, 94, 0.12)'
|
||||
: 'var(--mantine-color-dimmed-alpha, rgba(120,120,120,0.1))',
|
||||
color: isActive ? '#3b82f6' : isDone ? '#22c55e' : 'var(--mantine-color-dimmed)',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{isActive
|
||||
? t('smartFolders.status.processing', 'Processing')
|
||||
: isDone
|
||||
? t('smartFolders.status.done', 'Done')
|
||||
: t('smartFolders.status.idle', 'Idle')}
|
||||
</Box>
|
||||
</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>
|
||||
{/* Right: file count + hover actions */}
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }} onClick={(e) => e.stopPropagation()}>
|
||||
<Box style={{ textAlign: 'right' }}>
|
||||
<Text
|
||||
fw={700}
|
||||
size="sm"
|
||||
style={{ color: folder.accentColor, lineHeight: 1.2 }}
|
||||
>
|
||||
{fileCount}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ fontSize: '0.625rem', lineHeight: 1 }}>
|
||||
{fileCount === 1
|
||||
? t('smartFolders.home.file', 'file')
|
||||
: t('smartFolders.home.files', 'files')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Group
|
||||
gap={2}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => onEdit(folder)}
|
||||
aria-label={t('smartFolders.home.editFolder', 'Edit folder')}
|
||||
>
|
||||
<EditIcon style={{ fontSize: '0.9375rem' }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => onDelete(folder)}
|
||||
aria-label={t('smartFolders.home.deleteFolder', 'Delete folder')}
|
||||
>
|
||||
<DeleteOutlineIcon style={{ fontSize: '0.9375rem' }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
style={{ color: folder.accentColor }}
|
||||
onClick={() => onOpen(folder.id)}
|
||||
aria-label={t('smartFolders.home.openFolder', 'Open folder')}
|
||||
>
|
||||
<OpenInNewIcon style={{ fontSize: '0.9375rem' }} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Pipeline chips */}
|
||||
{ops.length > 0 && (
|
||||
<Group gap="xs" wrap="wrap" mb="xs">
|
||||
{ops.map((op, i) => (
|
||||
<Group key={i} gap={4} wrap="nowrap" align="center">
|
||||
{i > 0 && (
|
||||
<ChevronRightIcon
|
||||
style={{ fontSize: '0.75rem', color: 'var(--mantine-color-dimmed)', flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
style={{
|
||||
padding: '0.125rem 0.5rem',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: '0.75rem',
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
color: 'var(--mantine-color-text)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{humaniseOp(op.operation)}
|
||||
</Box>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
{ops.length === 0 && (
|
||||
<Text size="xs" c="dimmed" mb="xs" style={{ fontStyle: 'italic' }}>
|
||||
{t('smartFolders.home.noSteps', 'No automation steps configured')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Drop hint / description row */}
|
||||
<Group gap="xs" align="center">
|
||||
<UploadFileIcon
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
color: isDragOver ? folder.accentColor : 'var(--mantine-color-dimmed)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
size="xs"
|
||||
style={{
|
||||
color: isDragOver ? folder.accentColor : 'var(--mantine-color-dimmed)',
|
||||
fontWeight: isDragOver ? 600 : 400,
|
||||
fontSize: '0.6875rem',
|
||||
}}
|
||||
>
|
||||
{isDragOver
|
||||
? t('smartFolders.home.dropHere', 'Drop to process')
|
||||
: folder.description || t('smartFolders.home.dragHint', 'Drop PDFs to process')}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function HowItWorks() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const steps = [
|
||||
{
|
||||
n: '1',
|
||||
title: t('smartFolders.howItWorks.step1Title', 'Drop files'),
|
||||
desc: t('smartFolders.howItWorks.step1Desc', 'Drag PDFs onto any Watch Folder card — or send them from your file list'),
|
||||
},
|
||||
{
|
||||
n: '2',
|
||||
title: t('smartFolders.howItWorks.step2Title', 'Pipeline runs'),
|
||||
desc: t('smartFolders.howItWorks.step2Desc', 'Your configured tools process each file automatically'),
|
||||
},
|
||||
{
|
||||
n: '3',
|
||||
title: t('smartFolders.howItWorks.step3Title', 'Output ready'),
|
||||
desc: t('smartFolders.howItWorks.step3Desc', 'Download processed files from inside the folder'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
mt="lg"
|
||||
style={{
|
||||
padding: '1rem 1.25rem',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
border: '0.0625rem solid var(--border-subtle)',
|
||||
backgroundColor: 'var(--bg-toolbar)',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" mb="sm">
|
||||
<InfoOutlinedIcon style={{ fontSize: '1rem', color: 'var(--mantine-color-blue-filled)' }} />
|
||||
<Text fw={600} size="xs">
|
||||
{t('smartFolders.howItWorks.title', 'How Watch Folders work')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="xl" wrap="nowrap" align="flex-start">
|
||||
{steps.map((step) => (
|
||||
<Group key={step.n} gap="sm" wrap="nowrap" align="flex-start" style={{ flex: 1 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: '1.375rem',
|
||||
height: '1.375rem',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--mantine-color-blue-light)',
|
||||
color: 'var(--mantine-color-blue-filled)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{step.n}
|
||||
</Box>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={600}>
|
||||
{step.title}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ lineHeight: 1.5 }}>
|
||||
{step.desc}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4rem 2rem',
|
||||
gap: '1.5rem',
|
||||
maxWidth: '32rem',
|
||||
margin: '0 auto',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: '5rem',
|
||||
height: '5rem',
|
||||
borderRadius: '1.25rem',
|
||||
background: 'linear-gradient(135deg, rgba(59,130,246,0.15) 0%, transparent 100%)',
|
||||
border: '0.0625rem solid var(--border-subtle)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<FolderPlusIcon style={{ fontSize: '2rem', color: 'var(--mantine-color-blue-filled)' }} />
|
||||
</Box>
|
||||
|
||||
<Stack gap="xs" align="center">
|
||||
<Text fw={700} size="lg" style={{ letterSpacing: '-0.02em' }}>
|
||||
{t('smartFolders.home.emptyTitle', 'Automate your PDF workflows')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" style={{ lineHeight: 1.6, maxWidth: '22rem' }}>
|
||||
{t(
|
||||
'smartFolders.home.emptyDesc',
|
||||
"Set up a Watch Folder once. Drop PDFs in and they're automatically compressed, OCR'd, split, merged — whatever your pipeline does."
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
size="md"
|
||||
leftSection={<AddIcon style={{ fontSize: '1.125rem' }} />}
|
||||
onClick={onCreate}
|
||||
>
|
||||
{t('smartFolders.home.create', 'Create your first Watch Folder')}
|
||||
</Button>
|
||||
|
||||
<HowItWorks />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -218,10 +472,13 @@ export function SmartFolderHomePage() {
|
||||
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 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);
|
||||
@@ -236,44 +493,49 @@ export function SmartFolderHomePage() {
|
||||
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;
|
||||
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;
|
||||
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' });
|
||||
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' });
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setProcessingFolderIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(folder.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [toolRegistry]);
|
||||
},
|
||||
[toolRegistry]
|
||||
);
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
@@ -283,22 +545,32 @@ export function SmartFolderHomePage() {
|
||||
|
||||
return (
|
||||
<Box style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<style>{KEYFRAMES}</style>
|
||||
|
||||
{/* Header */}
|
||||
<Box
|
||||
px="md"
|
||||
py="sm"
|
||||
px="xl"
|
||||
py="md"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
borderBottom: '0.0625rem 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>
|
||||
<Stack gap={2}>
|
||||
<Text fw={700} size="lg" style={{ letterSpacing: '-0.02em' }}>
|
||||
{t('smartFolders.home.title', 'Watch Folders')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'smartFolders.home.subtitle',
|
||||
'Folders that automatically process PDFs with your configured pipeline'
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<AddIcon style={{ fontSize: 14 }} />}
|
||||
leftSection={<AddIcon style={{ fontSize: '1rem' }} />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
{t('smartFolders.newFolder', 'New folder')}
|
||||
@@ -308,30 +580,26 @@ export function SmartFolderHomePage() {
|
||||
|
||||
{/* Folder list */}
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Box p="sm">
|
||||
<Box p="xl">
|
||||
{loading ? (
|
||||
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem' }}>
|
||||
<Loader size="sm" />
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4rem',
|
||||
}}
|
||||
>
|
||||
<Loader size="md" />
|
||||
</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>
|
||||
<EmptyState onCreate={() => setCreateModalOpen(true)} />
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Stack gap="md">
|
||||
{folders.map((folder) => {
|
||||
const status = statuses[folder.id] ?? 'idle';
|
||||
const isProcessing = processingFolderIds.has(folder.id) || status === 'processing';
|
||||
const isProcessing =
|
||||
processingFolderIds.has(folder.id) || status === 'processing';
|
||||
return (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
@@ -339,11 +607,50 @@ export function SmartFolderHomePage() {
|
||||
status={status}
|
||||
isProcessing={isProcessing}
|
||||
onEdit={handleEdit}
|
||||
onDelete={setDeleteTarget}
|
||||
onOpen={navigateToFolder}
|
||||
onDropFiles={processFiles}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add another prompt */}
|
||||
<Box
|
||||
style={{
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
border: '0.09375rem dashed var(--border-subtle)',
|
||||
padding: '1.5rem',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--mantine-color-blue-filled)'; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--border-subtle)'; }}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: '3rem',
|
||||
height: '3rem',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 0.75rem',
|
||||
}}
|
||||
>
|
||||
<FolderPlusIcon style={{ fontSize: '1.375rem', color: 'var(--mantine-color-dimmed)' }} />
|
||||
</Box>
|
||||
<Text size="sm" fw={500} c="dimmed" mb={2}>
|
||||
{t('smartFolders.home.addAnother', 'Add another Watch Folder')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('smartFolders.home.addAnotherDesc', 'Automatically process files with a new pipeline')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<HowItWorks />
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,20 @@ interface OutputFileRecord {
|
||||
storedAt: number;
|
||||
}
|
||||
|
||||
interface InputFileRecord {
|
||||
fileId: string;
|
||||
folderId: string;
|
||||
name: string;
|
||||
blob: Blob;
|
||||
storedAt: number;
|
||||
}
|
||||
|
||||
class FolderStorage {
|
||||
private dbName = 'stirling-pdf-folder-files';
|
||||
private dbVersion = 1;
|
||||
private dbVersion = 2;
|
||||
private recordsStore = 'folderRecords';
|
||||
private outputStore = 'folderOutputFiles';
|
||||
private inputStore = 'folderInputFiles';
|
||||
private db: IDBDatabase | null = null;
|
||||
|
||||
async init(): Promise<void> {
|
||||
@@ -43,6 +52,10 @@ class FolderStorage {
|
||||
const outputStore = db.createObjectStore(this.outputStore, { keyPath: 'fileId' });
|
||||
outputStore.createIndex('folderId', 'folderId', { unique: false });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(this.inputStore)) {
|
||||
const inputStore = db.createObjectStore(this.inputStore, { keyPath: 'fileId' });
|
||||
inputStore.createIndex('folderId', 'folderId', { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -140,11 +153,54 @@ class FolderStorage {
|
||||
});
|
||||
}
|
||||
|
||||
async storeInputFile(folderId: string, fileId: string, blob: Blob, name: string): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
const record: InputFileRecord = { fileId, folderId, name, blob, storedAt: Date.now() };
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.inputStore], 'readwrite');
|
||||
const store = transaction.objectStore(this.inputStore);
|
||||
const request = store.put(record);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(new Error('Failed to store input file'));
|
||||
});
|
||||
}
|
||||
|
||||
async getInputFile(fileId: string): Promise<InputFileRecord | null> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.inputStore], 'readonly');
|
||||
const store = transaction.objectStore(this.inputStore);
|
||||
const request = store.get(fileId);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(new Error('Failed to get input file'));
|
||||
});
|
||||
}
|
||||
|
||||
async getInputFilesByFolder(folderId: string): Promise<InputFileRecord[]> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.inputStore], 'readonly');
|
||||
const store = transaction.objectStore(this.inputStore);
|
||||
const index = store.index('folderId');
|
||||
const request = index.getAll(folderId);
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
request.onerror = () => reject(new Error('Failed to get input files by folder'));
|
||||
});
|
||||
}
|
||||
|
||||
async clearFolder(folderId: string): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
// Delete output files for this folder
|
||||
// Delete output and input files for this folder
|
||||
const outputFileIds = await this.getOutputFileIdsByFolder(folderId);
|
||||
await Promise.all(outputFileIds.map(id => this.deleteOutputFile(db, id)));
|
||||
const inputFiles = await this.getInputFilesByFolder(folderId);
|
||||
await Promise.all(inputFiles.map(f => new Promise<void>((resolve, reject) => {
|
||||
const transaction = db.transaction([this.inputStore], 'readwrite');
|
||||
const store = transaction.objectStore(this.inputStore);
|
||||
const request = store.delete(f.fileId);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(new Error('Failed to delete input file'));
|
||||
})));
|
||||
// Delete folder record
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.recordsStore], 'readwrite');
|
||||
@@ -250,4 +306,4 @@ class FolderStorage {
|
||||
}
|
||||
|
||||
export const folderStorage = new FolderStorage();
|
||||
export type { OutputFileRecord };
|
||||
export type { OutputFileRecord, InputFileRecord };
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface FolderFileMetadata {
|
||||
displayFileId?: string; // output file id
|
||||
inputFileId?: string;
|
||||
errorMessage?: string;
|
||||
name?: string; // original filename
|
||||
}
|
||||
|
||||
export interface FolderRecord {
|
||||
|
||||
Reference in New Issue
Block a user