Ui overhaul step 1

This commit is contained in:
Reece
2026-03-09 16:26:49 +00:00
parent df65620d85
commit 1aff53e405
4 changed files with 1165 additions and 450 deletions
@@ -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
+59 -3
View File
@@ -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 };
+1
View File
@@ -22,6 +22,7 @@ export interface FolderFileMetadata {
displayFileId?: string; // output file id
inputFileId?: string;
errorMessage?: string;
name?: string; // original filename
}
export interface FolderRecord {