mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Feature/v2/smartfolders rebuild (#6480)
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
parent
e0fc5061de
commit
e2536daeb8
@@ -0,0 +1,278 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, ActionIcon, ScrollArea } from "@mantine/core";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import {
|
||||
CardModalPhase,
|
||||
CARD_MODAL_TIMINGS,
|
||||
} from "@app/hooks/useCardModalAnimation";
|
||||
|
||||
interface CardExpansionModalProps {
|
||||
phase: CardModalPhase;
|
||||
cardRect: DOMRect | null;
|
||||
textExpanded: boolean;
|
||||
onClose: () => void;
|
||||
icon: React.ReactNode;
|
||||
count: number;
|
||||
labelSingular: string;
|
||||
labelPlural: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
toolbar?: React.ReactNode;
|
||||
widthRem?: number;
|
||||
heightRem?: number;
|
||||
/** Replace ScrollArea with a flex-fill container so children can expand to full body height */
|
||||
fillHeight?: boolean;
|
||||
}
|
||||
|
||||
const MODAL_W_REM = 56;
|
||||
const MODAL_H_REM = 38;
|
||||
const HEADER_H_REM = 3.5;
|
||||
const MODAL_TOP_FRACTION = 0.12;
|
||||
const EASING = "cubic-bezier(0.22,1,0.36,1)";
|
||||
|
||||
export function CardExpansionModal({
|
||||
phase,
|
||||
cardRect,
|
||||
textExpanded,
|
||||
onClose,
|
||||
icon,
|
||||
count,
|
||||
labelSingular,
|
||||
labelPlural,
|
||||
children,
|
||||
footer,
|
||||
toolbar,
|
||||
widthRem,
|
||||
heightRem,
|
||||
fillHeight,
|
||||
}: CardExpansionModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [viewportW, setViewportW] = useState(window.innerWidth);
|
||||
const [viewportH, setViewportH] = useState(window.innerHeight);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setViewportW(window.innerWidth);
|
||||
setViewportH(window.innerHeight);
|
||||
};
|
||||
window.addEventListener("resize", handler);
|
||||
return () => window.removeEventListener("resize", handler);
|
||||
}, []);
|
||||
|
||||
if (phase === "closed" || !cardRect) return null;
|
||||
|
||||
const rootFontSize = parseFloat(
|
||||
getComputedStyle(document.documentElement).fontSize,
|
||||
);
|
||||
const modalW = Math.min(
|
||||
(widthRem ?? MODAL_W_REM) * rootFontSize,
|
||||
viewportW * 0.9,
|
||||
);
|
||||
const modalH = Math.min(
|
||||
(heightRem ?? MODAL_H_REM) * rootFontSize,
|
||||
viewportH * 0.85,
|
||||
);
|
||||
const headerH = HEADER_H_REM * rootFontSize;
|
||||
const finalLeft = (viewportW - modalW) / 2;
|
||||
const finalTop = Math.min(
|
||||
viewportH * MODAL_TOP_FRACTION,
|
||||
viewportH - modalH - 16,
|
||||
);
|
||||
|
||||
const isAtCard = phase === "entering" || phase === "closing-header";
|
||||
const isAtHeader = phase === "header-open";
|
||||
|
||||
const cardH = isAtCard ? cardRect.height : isAtHeader ? headerH : modalH;
|
||||
|
||||
const getTransition = () => {
|
||||
const s = CARD_MODAL_TIMINGS;
|
||||
if (phase === "header-open")
|
||||
return `top ${s.headerStretch}ms ${EASING}, left ${s.headerStretch}ms ${EASING}, width ${s.headerStretch}ms ${EASING}, height ${s.headerStretch}ms ${EASING}`;
|
||||
if (phase === "open") return `height ${s.bodyDrop}ms ${EASING}`;
|
||||
if (phase === "closing-body") return `height ${s.closeBody}ms ease-in`;
|
||||
if (phase === "closing-header")
|
||||
return `top ${s.closeStretch}ms ${EASING}, left ${s.closeStretch}ms ${EASING}, width ${s.closeStretch}ms ${EASING}, height ${s.closeStretch}ms ${EASING}, opacity ${s.closeStretch}ms ease`;
|
||||
return "none";
|
||||
};
|
||||
|
||||
const backdropOpacity =
|
||||
phase === "entering" || phase === "closing-header" ? 0 : 1;
|
||||
const cardOpacity = phase === "closing-header" ? 0 : 1;
|
||||
|
||||
const showBody = phase === "open" || phase === "closing-body";
|
||||
|
||||
return createPortal(
|
||||
<div style={{ position: "fixed", inset: 0, zIndex: 300 }}>
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
opacity: backdropOpacity,
|
||||
transition: "opacity 220ms ease",
|
||||
willChange: "opacity",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: isAtCard ? cardRect.top : finalTop,
|
||||
left: isAtCard ? cardRect.left : finalLeft,
|
||||
width: isAtCard ? cardRect.width : modalW,
|
||||
height: cardH,
|
||||
opacity: cardOpacity,
|
||||
transition: getTransition(),
|
||||
willChange: "top, left, width, height, opacity",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "var(--bg-toolbar)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxShadow: "0 1.5rem 3rem rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
height: headerH,
|
||||
flexShrink: 0,
|
||||
borderBottom: "0.0625rem solid var(--border-subtle)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "1rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<Text
|
||||
component="span"
|
||||
fw={800}
|
||||
style={{ fontSize: "1.375rem", lineHeight: 1, margin: 0 }}
|
||||
>
|
||||
{count}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: textExpanded ? "16rem" : "0",
|
||||
opacity: textExpanded ? 1 : 0,
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
transition: `max-width 100ms ${EASING}, opacity 80ms ease`,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
component="span"
|
||||
c="dimmed"
|
||||
style={{
|
||||
fontSize: "1rem",
|
||||
paddingLeft: "0.5rem",
|
||||
lineHeight: 1,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{count === 1 ? labelSingular : labelPlural}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
color="gray"
|
||||
onClick={onClose}
|
||||
aria-label={t("close", "Close")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0.25rem",
|
||||
right: "0.375rem",
|
||||
}}
|
||||
>
|
||||
<CloseIcon style={{ fontSize: "1.25rem" }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showBody && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
backgroundColor: "var(--bg-toolbar)",
|
||||
}}
|
||||
>
|
||||
{toolbar && (
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: "0.0625rem solid var(--border-subtle)",
|
||||
}}
|
||||
>
|
||||
{toolbar}
|
||||
</div>
|
||||
)}
|
||||
{fillHeight ? (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
padding: "0.75rem 1rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
backgroundColor: "var(--bg-toolbar)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
{footer && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
borderTop: "0.0625rem solid var(--border-subtle)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { Modal, Text, Button, Stack, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { WatchedFolder } from "@app/types/watchedFolders";
|
||||
|
||||
interface DeleteFolderConfirmModalProps {
|
||||
opened: boolean;
|
||||
folder: WatchedFolder | null;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function DeleteFolderConfirmModal({
|
||||
opened,
|
||||
folder,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DeleteFolderConfirmModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!folder) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onCancel}
|
||||
title={t("watchedFolders.deleteConfirmTitle", "Delete folder?")}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{folder.isDefault && (
|
||||
<Text size="sm" c="orange">
|
||||
{t(
|
||||
"watchedFolders.defaultFolderWarning",
|
||||
"This is a default folder and will be recreated on next reload.",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"watchedFolders.deleteConfirmBody",
|
||||
"This will remove the folder and its run history. Files already downloaded are not affected.",
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="sm" justify="flex-end">
|
||||
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button color="red" size="sm" onClick={onConfirm}>
|
||||
{t("delete", "Delete")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Center, Text, Box, Loader } from "@mantine/core";
|
||||
import { FileId } from "@app/types/fileContext";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF";
|
||||
import { PdfViewerToolbar } from "@app/components/viewer/PdfViewerToolbar";
|
||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
fileId?: FileId | null;
|
||||
/** Pass a File directly (e.g. outputs not stored in IDB). */
|
||||
file?: File | null;
|
||||
fileName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FilePreviewModal({
|
||||
fileId,
|
||||
file: fileProp,
|
||||
fileName,
|
||||
onClose,
|
||||
}: FilePreviewModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileProp) {
|
||||
setFile(fileProp);
|
||||
setError(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!fileId) {
|
||||
setFile(null);
|
||||
setError(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setError(false);
|
||||
setLoading(true);
|
||||
fileStorage
|
||||
.getStirlingFile(fileId)
|
||||
.then((f) => {
|
||||
if (f) setFile(f);
|
||||
else setError(true);
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [fileId, fileProp]);
|
||||
|
||||
const opened = !!(fileId || fileProp);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={fileName}
|
||||
size="90%"
|
||||
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
|
||||
styles={{
|
||||
body: {
|
||||
height: "82vh",
|
||||
padding: 0,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : error ? (
|
||||
<Center h="100%">
|
||||
<Text c="dimmed">
|
||||
{t(
|
||||
"watchedFolders.workbench.previewLoadFailed",
|
||||
"Could not load file preview.",
|
||||
)}
|
||||
</Text>
|
||||
</Center>
|
||||
) : !file ? (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : (
|
||||
<ViewerProvider>
|
||||
<PdfViewerToolbar />
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
<LocalEmbedPDF file={file} fileName={fileName} />
|
||||
</Box>
|
||||
</ViewerProvider>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Icon picker for Watched 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,72 @@
|
||||
import React from "react";
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
interface StatCardProps {
|
||||
icon: React.ReactNode;
|
||||
count: React.ReactNode;
|
||||
label: string;
|
||||
hoverColor?: string;
|
||||
onClick?: (rect: DOMRect) => void;
|
||||
disabled?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
icon,
|
||||
count,
|
||||
label,
|
||||
hoverColor,
|
||||
onClick,
|
||||
disabled,
|
||||
isActive,
|
||||
}: StatCardProps) {
|
||||
const isClickable = !!onClick && !disabled;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
if (isClickable) onClick(e.currentTarget.getBoundingClientRect());
|
||||
}}
|
||||
style={{
|
||||
padding: "0.5rem 0.75rem 2rem",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
border: `0.0625rem solid ${isActive && hoverColor ? hoverColor : "var(--border-subtle)"}`,
|
||||
backgroundColor:
|
||||
isActive && hoverColor ? `${hoverColor}10` : "var(--bg-toolbar)",
|
||||
textAlign: "center",
|
||||
cursor: isClickable ? "pointer" : "default",
|
||||
transition: "border-color 0.15s ease, background-color 0.15s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isClickable && hoverColor && !isActive)
|
||||
e.currentTarget.style.borderColor = hoverColor;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (isClickable && !isActive)
|
||||
e.currentTarget.style.borderColor = "var(--border-subtle)";
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "block", marginBottom: "0.375rem" }}>{icon}</div>
|
||||
<Text
|
||||
fw={800}
|
||||
style={{
|
||||
fontSize: "1.375rem",
|
||||
lineHeight: 1,
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: "0.5625rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
}}
|
||||
c="dimmed"
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
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 PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutlined";
|
||||
import { WatchedFolder } from "@app/types/watchedFolders";
|
||||
import { FolderRunStatus } from "@app/hooks/useFolderRunStatuses";
|
||||
import { iconMap } from "@app/components/tools/automate/iconMap";
|
||||
|
||||
interface WatchedFolderCardProps {
|
||||
folder: WatchedFolder;
|
||||
isActive: boolean;
|
||||
status: FolderRunStatus;
|
||||
onSelect: () => void;
|
||||
onEdit: (e: React.MouseEvent) => void;
|
||||
onDelete: (e: React.MouseEvent) => void;
|
||||
onFileDrop?: (fileIds: string[]) => void;
|
||||
}
|
||||
|
||||
export function WatchedFolderCard({
|
||||
folder,
|
||||
isActive,
|
||||
status,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onFileDrop,
|
||||
}: WatchedFolderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const IconComponent =
|
||||
iconMap[folder.icon as keyof typeof iconMap] || iconMap.FolderIcon;
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
const types = e.dataTransfer.types;
|
||||
if (
|
||||
!types.includes("watchedFolderFileId") &&
|
||||
!types.includes("watchedFolderFileIds")
|
||||
)
|
||||
return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => setIsDragOver(false);
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const multiRaw = e.dataTransfer.getData("watchedFolderFileIds");
|
||||
if (multiRaw) {
|
||||
try {
|
||||
const ids: string[] = JSON.parse(multiRaw);
|
||||
if (ids.length > 0 && onFileDrop) onFileDrop(ids);
|
||||
return;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const fileId = e.dataTransfer.getData("watchedFolderFileId");
|
||||
if (fileId && onFileDrop) onFileDrop([fileId]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="tool-button-container"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
style={
|
||||
isDragOver
|
||||
? {
|
||||
backgroundColor: "rgba(59,130,246,0.10)",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<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("watchedFolders.card.edit", "Edit folder")}
|
||||
>
|
||||
<EditIcon style={{ fontSize: 11 }} />
|
||||
</ActionIcon>
|
||||
{!folder.isDefault && (
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={onDelete}
|
||||
aria-label={t("watchedFolders.card.delete", "Delete folder")}
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: 11 }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
) : folder.isPaused ? (
|
||||
<PauseCircleOutlineIcon
|
||||
style={{ fontSize: 12, color: "var(--mantine-color-dimmed)" }}
|
||||
/>
|
||||
) : status === "processing" ? (
|
||||
<Loader size={10} color={folder.accentColor} />
|
||||
) : status === "done" ? (
|
||||
<CheckCircleIcon
|
||||
style={{ fontSize: 12, color: "var(--color-green-500)" }}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{folder.name}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
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 EditIcon from "@mui/icons-material/Edit";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import FolderPlusIcon from "@mui/icons-material/CreateNewFolder";
|
||||
import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutlined";
|
||||
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutlined";
|
||||
import { useWatchedFolders } from "@app/hooks/useWatchedFolders";
|
||||
import { useFolderRunStatuses } from "@app/hooks/useFolderRunStatuses";
|
||||
import {
|
||||
useFolderAutomation,
|
||||
resolveInputFile,
|
||||
} from "@app/hooks/useFolderAutomation";
|
||||
import { WatchedFolder } from "@app/types/watchedFolders";
|
||||
import { type FileId } from "@app/types/file";
|
||||
import { AutomationConfig } from "@app/types/automation";
|
||||
import { automationStorage } from "@app/services/automationStorage";
|
||||
import { watchedFolderFileStorage } from "@app/services/watchedFolderFileStorage";
|
||||
import { getWatchedFolderDraggedFileIds } from "@app/components/watchedFolders/watchedFolderDragState";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail";
|
||||
import { WatchedFolderManagementModal } from "@app/components/watchedFolders/WatchedFolderManagementModal";
|
||||
import { DeleteFolderConfirmModal } from "@app/components/watchedFolders/DeleteFolderConfirmModal";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import {
|
||||
WATCHED_FOLDER_VIEW_ID,
|
||||
WATCHED_FOLDER_WORKBENCH_ID,
|
||||
} from "@app/components/watchedFolders/WatchedFoldersRegistration";
|
||||
import { timeAgo } from "@app/components/watchedFolders/WatchedFolderWorkbenchView";
|
||||
import "@app/components/watchedFolders/WatchedFolders.css";
|
||||
|
||||
export function humaniseOp(op: string): string {
|
||||
return op
|
||||
.replace(/-pdf$|-pages$|-documents?$/i, "")
|
||||
.replace(/[-_]/g, " ")
|
||||
.replace(/\bocr\b/gi, "OCR")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
interface FolderCardProps {
|
||||
folder: WatchedFolder;
|
||||
status: "idle" | "processing" | "done";
|
||||
isProcessing: boolean;
|
||||
onEdit: (folder: WatchedFolder) => void;
|
||||
onDelete: (folder: WatchedFolder) => void;
|
||||
onOpen: (folderId: string) => void;
|
||||
onDropFiles: (folder: WatchedFolder, files: File[]) => void;
|
||||
onDropSidebarFile: (folder: WatchedFolder, fileIds: string[]) => void;
|
||||
onTogglePause: (folder: WatchedFolder) => void;
|
||||
}
|
||||
|
||||
function FolderCard({
|
||||
folder,
|
||||
status,
|
||||
isProcessing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onOpen,
|
||||
onDropFiles,
|
||||
onDropSidebarFile,
|
||||
onTogglePause,
|
||||
}: FolderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [automation, setAutomation] = useState<AutomationConfig | null>(null);
|
||||
const [fileCount, setFileCount] = useState(0);
|
||||
const [lastAdded, setLastAdded] = useState<Date | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
// True while a drag hovers this card AND every dragged file is already in it
|
||||
// (so the drop would be a no-op — see the guard in processFiles).
|
||||
const [dragAlreadyPresent, setDragAlreadyPresent] = useState(false);
|
||||
// Ids of files currently in this folder, used for the live dragover check.
|
||||
const [memberIds, setMemberIds] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
automationStorage.getAutomation(folder.automationId).then(setAutomation);
|
||||
|
||||
const loadData = () =>
|
||||
watchedFolderFileStorage.getFolderData(folder.id).then((record) => {
|
||||
if (!record) {
|
||||
setFileCount(0);
|
||||
setLastAdded(null);
|
||||
setMemberIds(new Set());
|
||||
return;
|
||||
}
|
||||
const files = Object.values(record.files);
|
||||
setFileCount(files.length);
|
||||
setMemberIds(new Set(Object.keys(record.files)));
|
||||
const dates = files
|
||||
.map((f) => new Date(f.addedAt))
|
||||
.filter((d) => !isNaN(d.getTime()));
|
||||
setLastAdded(
|
||||
dates.length
|
||||
? new Date(Math.max(...dates.map((d) => d.getTime())))
|
||||
: null,
|
||||
);
|
||||
});
|
||||
loadData();
|
||||
|
||||
const unsub = watchedFolderFileStorage.onFolderChange((changedId) => {
|
||||
if (changedId === folder.id) loadData();
|
||||
});
|
||||
return unsub;
|
||||
}, [folder.id, folder.automationId]);
|
||||
|
||||
const isPaused = folder.isPaused ?? false;
|
||||
const isActive = !isPaused && (isProcessing || status === "processing");
|
||||
const isDone = !isPaused && status === "done" && !isActive;
|
||||
|
||||
const statusDotColor = isPaused
|
||||
? "var(--mantine-color-dimmed)"
|
||||
: isActive
|
||||
? "var(--mantine-color-blue-filled)"
|
||||
: isDone
|
||||
? "var(--color-green-500)"
|
||||
: "var(--text-muted)";
|
||||
const statusDotPulse = isActive;
|
||||
|
||||
const statusLabel = isPaused
|
||||
? t("watchedFolders.status.paused", "Paused")
|
||||
: isActive
|
||||
? t("watchedFolders.status.processing", "Processing")
|
||||
: isDone
|
||||
? t("watchedFolders.status.done", "Done")
|
||||
: t("watchedFolders.status.active", "Active");
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
// Live feedback: are all the files being dragged already in this folder?
|
||||
const dragged = getWatchedFolderDraggedFileIds();
|
||||
setDragAlreadyPresent(
|
||||
dragged.length > 0 && dragged.every((id) => memberIds.has(id)),
|
||||
);
|
||||
};
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsDragOver(false);
|
||||
setDragAlreadyPresent(false);
|
||||
}
|
||||
};
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
setDragAlreadyPresent(false);
|
||||
const multiRaw = e.dataTransfer.getData("watchedFolderFileIds");
|
||||
if (multiRaw) {
|
||||
try {
|
||||
const ids: string[] = JSON.parse(multiRaw);
|
||||
if (ids.length > 0) {
|
||||
onDropSidebarFile(folder, ids);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const sidebarFileId = e.dataTransfer.getData("watchedFolderFileId");
|
||||
if (sidebarFileId) {
|
||||
onDropSidebarFile(folder, [sidebarFileId]);
|
||||
} else if (e.dataTransfer.files.length > 0) {
|
||||
onDropFiles(folder, Array.from(e.dataTransfer.files));
|
||||
}
|
||||
};
|
||||
|
||||
// `automation` is loaded by the per-card effect above; the steps it holds
|
||||
// are no longer rendered on the card but the load is preserved.
|
||||
void automation;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`wf-card${isDragOver ? " is-drop-target" : ""}${
|
||||
isDragOver && dragAlreadyPresent ? " is-already-member" : ""
|
||||
}`}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => onOpen(folder.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onOpen(folder.id);
|
||||
}}
|
||||
>
|
||||
{isDragOver && dragAlreadyPresent && (
|
||||
<div className="wf-card-drag-message" aria-hidden="true">
|
||||
{t("watchedFolders.alreadyInFolder", "Already in this folder")}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="wf-card-thumb"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, color-mix(in srgb, ${folder.accentColor} 18%, var(--bg-surface)), color-mix(in srgb, ${folder.accentColor} 6%, var(--bg-surface)))`,
|
||||
}}
|
||||
>
|
||||
<FolderThumbnail
|
||||
color={folder.accentColor}
|
||||
fileCount={fileCount}
|
||||
size="thumb"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wf-card-body">
|
||||
<div className="wf-card-name" title={folder.name}>
|
||||
{folder.name}
|
||||
</div>
|
||||
<div className="wf-card-meta">
|
||||
<span>
|
||||
{fileCount}{" "}
|
||||
{fileCount === 1
|
||||
? t("watchedFolders.home.file", "file")
|
||||
: t("watchedFolders.home.files", "files")}
|
||||
</span>
|
||||
{lastAdded && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(lastAdded, t)}</span>
|
||||
</>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
<span
|
||||
className={`wf-card-status-dot${statusDotPulse ? " is-pulsing" : ""}`}
|
||||
style={{ backgroundColor: statusDotColor }}
|
||||
/>
|
||||
<span style={{ fontSize: "0.7rem" }}>{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wf-card-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
onClick={() => onTogglePause(folder)}
|
||||
aria-label={
|
||||
isPaused
|
||||
? t("watchedFolders.home.resume", "Resume")
|
||||
: t("watchedFolders.home.pause", "Pause")
|
||||
}
|
||||
title={
|
||||
isPaused
|
||||
? t("watchedFolders.home.resume", "Resume")
|
||||
: t("watchedFolders.home.pause", "Pause")
|
||||
}
|
||||
>
|
||||
{isPaused ? (
|
||||
<PlayCircleOutlineIcon style={{ fontSize: "1.125rem" }} />
|
||||
) : (
|
||||
<PauseCircleOutlineIcon style={{ fontSize: "1.125rem" }} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
onClick={() => onEdit(folder)}
|
||||
aria-label={t("watchedFolders.home.editFolder", "Edit folder")}
|
||||
>
|
||||
<EditIcon style={{ fontSize: "1.125rem" }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => onDelete(folder)}
|
||||
aria-label={t("watchedFolders.home.deleteFolder", "Delete folder")}
|
||||
>
|
||||
<DeleteOutlineIcon style={{ fontSize: "1.125rem" }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HowItWorks() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [dismissed, setDismissed] = useState(
|
||||
() => sessionStorage.getItem("wf_howItWorks_dismissed") === "1",
|
||||
);
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
const steps = [
|
||||
{
|
||||
n: "1",
|
||||
title: t("watchedFolders.howItWorks.step1Title", "Drop files"),
|
||||
desc: t(
|
||||
"watchedFolders.howItWorks.step1Desc",
|
||||
"Drag PDFs onto any Watched Folder card — or send them from your file list",
|
||||
),
|
||||
},
|
||||
{
|
||||
n: "2",
|
||||
title: t("watchedFolders.howItWorks.step2Title", "Pipeline runs"),
|
||||
desc: t(
|
||||
"watchedFolders.howItWorks.step2Desc",
|
||||
"Your configured tools process each file automatically",
|
||||
),
|
||||
},
|
||||
{
|
||||
n: "3",
|
||||
title: t("watchedFolders.howItWorks.step3Title", "Output ready"),
|
||||
desc: t(
|
||||
"watchedFolders.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" justify="space-between">
|
||||
<Group gap="xs">
|
||||
<InfoOutlinedIcon
|
||||
style={{
|
||||
fontSize: "1rem",
|
||||
color: "var(--mantine-color-blue-filled)",
|
||||
}}
|
||||
/>
|
||||
<Text fw={600} size="xs">
|
||||
{t("watchedFolders.howItWorks.title", "How Watched Folders work")}
|
||||
</Text>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
sessionStorage.setItem("wf_howItWorks_dismissed", "1");
|
||||
setDismissed(true);
|
||||
}}
|
||||
aria-label={t("watchedFolders.actions.dismiss", "Dismiss")}
|
||||
>
|
||||
<CloseIcon
|
||||
style={{ fontSize: "0.75rem", color: "var(--mantine-color-text)" }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</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>
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="wf-empty">
|
||||
<span className="wf-empty-icon">
|
||||
<FolderPlusIcon style={{ fontSize: "2.5rem" }} />
|
||||
</span>
|
||||
<div className="wf-empty-title">
|
||||
{t("watchedFolders.home.emptyTitle", "Automate your PDF workflows")}
|
||||
</div>
|
||||
<div className="wf-empty-hint">
|
||||
{t(
|
||||
"watchedFolders.home.emptyDesc",
|
||||
"Set up a Watched Folder once. Drop PDFs in and they're automatically compressed, OCR'd, split, merged — whatever your pipeline does.",
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="md"
|
||||
leftSection={<AddIcon style={{ fontSize: "1.125rem" }} />}
|
||||
onClick={onCreate}
|
||||
mt="sm"
|
||||
>
|
||||
{t("watchedFolders.home.create", "Create your first Watched Folder")}
|
||||
</Button>
|
||||
|
||||
<HowItWorks />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WatchedFolderHomePage() {
|
||||
const { t } = useTranslation();
|
||||
const { folders, loading, deleteFolder, updateFolder, refreshFolders } =
|
||||
useWatchedFolders();
|
||||
const statuses = useFolderRunStatuses(folders);
|
||||
const { toolRegistry, setCustomWorkbenchViewData } = useToolWorkflow();
|
||||
const { actions } = useNavigationActions();
|
||||
const { processBatch } = useFolderAutomation(toolRegistry);
|
||||
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editFolder, setEditFolder] = useState<WatchedFolder | null>(null);
|
||||
const [editAutomation, setEditAutomation] = useState<AutomationConfig | null>(
|
||||
null,
|
||||
);
|
||||
const [processingFolderIds, setProcessingFolderIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [deleteTarget, setDeleteTarget] = useState<WatchedFolder | null>(null);
|
||||
|
||||
const navigateToFolder = useCallback(
|
||||
(folderId: string) => {
|
||||
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId });
|
||||
actions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
|
||||
},
|
||||
[setCustomWorkbenchViewData, actions],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(async (folder: WatchedFolder) => {
|
||||
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: WatchedFolder, files: File[]) => {
|
||||
const pdfs = files.filter((f) => f.name.toLowerCase().endsWith(".pdf"));
|
||||
if (pdfs.length === 0) return;
|
||||
|
||||
// Load existing folder data once so we can skip files already in the folder.
|
||||
const existingData = await watchedFolderFileStorage.getFolderData(
|
||||
folder.id,
|
||||
);
|
||||
// Register files sequentially — addFileToFolder/updateFileMetadata are read-modify-write
|
||||
// without IDB transactions, so concurrent calls on the same folder lose updates.
|
||||
const items: Array<{
|
||||
file: File;
|
||||
inputFileId: string;
|
||||
ownedByFolder: boolean;
|
||||
}> = [];
|
||||
for (const file of pdfs) {
|
||||
const { inputFileId, ownedByFolder } = await resolveInputFile(file);
|
||||
// Guard against multiple adds: if this file is already in the folder,
|
||||
// skip it entirely rather than resetting its status and reprocessing.
|
||||
// (Dropping the same sidebar file onto a card again is a no-op.)
|
||||
if (existingData?.files[inputFileId]) {
|
||||
continue;
|
||||
}
|
||||
await watchedFolderFileStorage.addFileToFolder(folder.id, inputFileId, {
|
||||
status: "pending",
|
||||
name: file.name,
|
||||
ownedByFolder: ownedByFolder || undefined,
|
||||
});
|
||||
items.push({ file, inputFileId, ownedByFolder });
|
||||
}
|
||||
|
||||
// Nothing new to process (every dropped file was already in the folder).
|
||||
if (items.length === 0) return;
|
||||
|
||||
if (folder.isPaused) return;
|
||||
|
||||
setProcessingFolderIds((prev) => new Set([...prev, folder.id]));
|
||||
try {
|
||||
await processBatch(folder, items);
|
||||
} finally {
|
||||
setProcessingFolderIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(folder.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[processBatch],
|
||||
);
|
||||
|
||||
const handleTogglePause = useCallback(
|
||||
async (folder: WatchedFolder) => {
|
||||
const resuming = folder.isPaused;
|
||||
const updatedFolder = { ...folder, isPaused: !folder.isPaused };
|
||||
await updateFolder(updatedFolder);
|
||||
refreshFolders();
|
||||
|
||||
if (resuming) {
|
||||
const record = await watchedFolderFileStorage.getFolderData(folder.id);
|
||||
if (record) {
|
||||
const pendingEntries = Object.entries(record.files).filter(
|
||||
([, meta]) => meta.status === "pending",
|
||||
);
|
||||
if (pendingEntries.length > 0) {
|
||||
const items: Array<{
|
||||
file: File;
|
||||
inputFileId: string;
|
||||
ownedByFolder: boolean;
|
||||
}> = [];
|
||||
for (const [id, meta] of pendingEntries) {
|
||||
const stirlingFile = await fileStorage.getStirlingFile(
|
||||
id as FileId,
|
||||
);
|
||||
if (stirlingFile) {
|
||||
items.push({
|
||||
file: stirlingFile,
|
||||
inputFileId: id,
|
||||
ownedByFolder: meta.ownedByFolder ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (items.length > 0) processBatch(updatedFolder, items);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[updateFolder, refreshFolders, processBatch],
|
||||
);
|
||||
|
||||
const handleDropSidebarFile = useCallback(
|
||||
async (folder: WatchedFolder, fileIds: string[]) => {
|
||||
const results = await Promise.all(
|
||||
fileIds.map((id) => fileStorage.getStirlingFile(id as FileId)),
|
||||
);
|
||||
const stirlingFiles = results.filter(Boolean) as File[];
|
||||
if (stirlingFiles.length > 0) processFiles(folder, stirlingFiles);
|
||||
},
|
||||
[processFiles],
|
||||
);
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
await deleteFolder(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Box p="xl">
|
||||
{loading ? (
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "4rem",
|
||||
}}
|
||||
>
|
||||
<Loader size="md" />
|
||||
</Box>
|
||||
) : folders.length === 0 ? (
|
||||
<EmptyState onCreate={() => setCreateModalOpen(true)} />
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<HowItWorks />
|
||||
|
||||
<div className="wf-grid" role="list">
|
||||
{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}
|
||||
onDelete={setDeleteTarget}
|
||||
onOpen={navigateToFolder}
|
||||
onDropFiles={processFiles}
|
||||
onDropSidebarFile={handleDropSidebarFile}
|
||||
onTogglePause={handleTogglePause}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<div
|
||||
className="wf-new-tile"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ")
|
||||
setCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="wf-new-tile-icon">
|
||||
<FolderPlusIcon style={{ fontSize: "1.5rem" }} />
|
||||
</span>
|
||||
<Text size="sm" fw={600}>
|
||||
{t(
|
||||
"watchedFolders.home.addAnother",
|
||||
"Add another Watched Folder",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"watchedFolders.home.addAnotherDesc",
|
||||
"Automatically process files with a new pipeline",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
|
||||
<WatchedFolderManagementModal
|
||||
opened={createModalOpen}
|
||||
editFolder={editFolder}
|
||||
existingAutomation={editAutomation}
|
||||
onClose={handleModalClose}
|
||||
onSaved={refreshFolders}
|
||||
/>
|
||||
<DeleteFolderConfirmModal
|
||||
opened={!!deleteTarget}
|
||||
folder={deleteTarget}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+911
@@ -0,0 +1,911 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import {
|
||||
Button,
|
||||
Stack,
|
||||
Group,
|
||||
TextInput,
|
||||
ColorInput,
|
||||
NumberInput,
|
||||
Text,
|
||||
Alert,
|
||||
Switch,
|
||||
Select,
|
||||
Box,
|
||||
Collapse,
|
||||
Tooltip,
|
||||
Modal,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { WatchedFolder } from "@app/types/watchedFolders";
|
||||
import { AutomationConfig, AutomationMode } from "@app/types/automation";
|
||||
import { IconPicker as IconSelector } from "@app/components/watchedFolders/IconPicker";
|
||||
import AutomationCreation from "@app/components/tools/automate/AutomationCreation";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
|
||||
import { folderDirectoryHandleStorage } from "@app/services/folderDirectoryHandleStorage";
|
||||
import {
|
||||
canReadLocalFolder,
|
||||
canWriteLocalFolder,
|
||||
FS_WRITE_UNSUPPORTED_MSG,
|
||||
} from "@app/utils/fsAccessCapability";
|
||||
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import {
|
||||
Z_INDEX_AUTOMATE_MODAL,
|
||||
Z_INDEX_AUTOMATE_DROPDOWN,
|
||||
} from "@app/styles/zIndex";
|
||||
|
||||
const ACCENT_SWATCHES = [
|
||||
"#3b82f6",
|
||||
"#0ea5e9",
|
||||
"#14b8a6",
|
||||
"#22c55e",
|
||||
"#f97316",
|
||||
"#ef4444",
|
||||
"#9333ea",
|
||||
"#ec4899",
|
||||
"#6366f1",
|
||||
"#eab308",
|
||||
"#64748b",
|
||||
"#0f172a",
|
||||
];
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
tt="uppercase"
|
||||
style={{
|
||||
letterSpacing: "0.06em",
|
||||
color: "var(--tool-subcategory-text-color)",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
interface WatchedFolderManagementModalProps {
|
||||
opened: boolean;
|
||||
editFolder?: WatchedFolder | null;
|
||||
existingAutomation?: AutomationConfig | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function WatchedFolderManagementModal({
|
||||
opened,
|
||||
editFolder,
|
||||
existingAutomation,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: WatchedFolderManagementModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
const isEditMode = !!editFolder;
|
||||
|
||||
const [name, setName] = useState(editFolder?.name ?? "");
|
||||
const [icon, setIcon] = useState(editFolder?.icon ?? "FolderIcon");
|
||||
const [accentColor, setAccentColor] = useState(
|
||||
editFolder?.accentColor ?? "#3b82f6",
|
||||
);
|
||||
const [maxRetries, setMaxRetries] = useState<number>(
|
||||
editFolder?.maxRetries ?? 3,
|
||||
);
|
||||
const [retryDelayMinutes, setRetryDelayMinutes] = useState<number>(
|
||||
editFolder?.retryDelayMinutes ?? 5,
|
||||
);
|
||||
const [outputMode, setOutputMode] = useState<"new_file" | "new_version">(
|
||||
editFolder?.outputMode ?? "new_file",
|
||||
);
|
||||
const [outputName, setOutputName] = useState(
|
||||
editFolder?.outputName ?? editFolder?.name ?? "",
|
||||
);
|
||||
const [outputNamePosition, setOutputNamePosition] = useState<
|
||||
"prefix" | "suffix" | "auto-number"
|
||||
>(editFolder?.outputNamePosition ?? "prefix");
|
||||
const [inputSource, setInputSource] = useState<
|
||||
NonNullable<WatchedFolder["inputSource"]>
|
||||
>(editFolder?.inputSource ?? "idb");
|
||||
const outputNameDirty = useRef(!!editFolder?.outputName);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [outputDirName, setOutputDirName] = useState<string | null>(
|
||||
editFolder?.hasOutputDirectory ? "(loading…)" : null,
|
||||
);
|
||||
const pendingDirHandle = useRef<FileSystemDirectoryHandle | null>(null);
|
||||
const [inputDirName, setInputDirName] = useState<string | null>(
|
||||
editFolder?.inputSource === "local-folder" ? "(loading…)" : null,
|
||||
);
|
||||
const pendingInputDirHandle = useRef<FileSystemDirectoryHandle | null>(null);
|
||||
const [nameError, setNameError] = useState("");
|
||||
const [automationError, setAutomationError] = useState("");
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(isEditMode);
|
||||
|
||||
const automationSaveTrigger = useRef<(() => void) | null>(null);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setName(editFolder?.name ?? "");
|
||||
setIcon(editFolder?.icon ?? "FolderIcon");
|
||||
setAccentColor(editFolder?.accentColor ?? "#3b82f6");
|
||||
setMaxRetries(editFolder?.maxRetries ?? 3);
|
||||
setRetryDelayMinutes(editFolder?.retryDelayMinutes ?? 5);
|
||||
setOutputMode(editFolder?.outputMode ?? "new_file");
|
||||
setOutputName(editFolder?.outputName ?? editFolder?.name ?? "");
|
||||
setOutputNamePosition(
|
||||
(editFolder?.outputNamePosition as "prefix" | "suffix" | "auto-number") ??
|
||||
"prefix",
|
||||
);
|
||||
setInputSource(editFolder?.inputSource ?? "idb");
|
||||
setInputDirName(
|
||||
editFolder?.inputSource === "local-folder" ? "(loading…)" : null,
|
||||
);
|
||||
outputNameDirty.current = !!editFolder?.outputName;
|
||||
setShowAdvanced(!!editFolder);
|
||||
setNameError("");
|
||||
setAutomationError("");
|
||||
setSaveError(null);
|
||||
setSaving(false);
|
||||
}, [editFolder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
resetState();
|
||||
pendingDirHandle.current = null;
|
||||
pendingInputDirHandle.current = null;
|
||||
if (editFolder?.hasOutputDirectory && editFolder.id) {
|
||||
folderDirectoryHandleStorage
|
||||
.get(editFolder.id)
|
||||
.then((h) => setOutputDirName(h?.name ?? null));
|
||||
} else {
|
||||
setOutputDirName(null);
|
||||
}
|
||||
if (editFolder?.inputSource === "local-folder" && editFolder.id) {
|
||||
folderDirectoryHandleStorage
|
||||
.getInput(editFolder.id)
|
||||
.then((h) => setInputDirName(h?.name ?? null));
|
||||
} else {
|
||||
setInputDirName(null);
|
||||
}
|
||||
}
|
||||
}, [opened, resetState, editFolder]);
|
||||
|
||||
const handleClose = () => {
|
||||
resetState();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleAutomationComplete = useCallback(
|
||||
async (automation: AutomationConfig) => {
|
||||
const trimmedName = name.trim();
|
||||
|
||||
try {
|
||||
const retryFields = { maxRetries, retryDelayMinutes };
|
||||
const hasOutputDirectory = outputDirName !== null;
|
||||
const folderData = {
|
||||
name: trimmedName,
|
||||
description: "",
|
||||
icon,
|
||||
accentColor,
|
||||
automationId: automation.id,
|
||||
...retryFields,
|
||||
outputMode:
|
||||
outputMode === "new_version" ? ("new_version" as const) : undefined,
|
||||
outputName: outputName.trim() || undefined,
|
||||
outputNamePosition:
|
||||
outputNamePosition !== "prefix" ? outputNamePosition : undefined,
|
||||
hasOutputDirectory,
|
||||
inputSource: inputSource !== "idb" ? inputSource : undefined,
|
||||
};
|
||||
|
||||
if (isEditMode && editFolder) {
|
||||
const wasLocalFolder = editFolder.inputSource === "local-folder";
|
||||
await watchedFolderStorage.updateFolder({
|
||||
...editFolder,
|
||||
...folderData,
|
||||
});
|
||||
if (pendingDirHandle.current) {
|
||||
await folderDirectoryHandleStorage.set(
|
||||
editFolder.id,
|
||||
pendingDirHandle.current,
|
||||
);
|
||||
} else if (!hasOutputDirectory) {
|
||||
await folderDirectoryHandleStorage.remove(editFolder.id);
|
||||
}
|
||||
if (inputSource === "local-folder") {
|
||||
if (pendingInputDirHandle.current) {
|
||||
await folderDirectoryHandleStorage.setInput(
|
||||
editFolder.id,
|
||||
pendingInputDirHandle.current,
|
||||
);
|
||||
}
|
||||
} else if (wasLocalFolder) {
|
||||
await folderDirectoryHandleStorage.removeInput(editFolder.id);
|
||||
}
|
||||
} else {
|
||||
const newFolder = await watchedFolderStorage.createFolder(folderData);
|
||||
if (pendingDirHandle.current) {
|
||||
await folderDirectoryHandleStorage.set(
|
||||
newFolder.id,
|
||||
pendingDirHandle.current,
|
||||
);
|
||||
}
|
||||
if (inputSource === "local-folder" && pendingInputDirHandle.current) {
|
||||
await folderDirectoryHandleStorage.setInput(
|
||||
newFolder.id,
|
||||
pendingInputDirHandle.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
resetState();
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save smart folder:", error);
|
||||
setSaveError(
|
||||
t(
|
||||
"watchedFolders.modal.saveFailed",
|
||||
"Failed to save folder. Please try again.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
name,
|
||||
icon,
|
||||
accentColor,
|
||||
outputMode,
|
||||
outputName,
|
||||
outputNamePosition,
|
||||
outputDirName,
|
||||
maxRetries,
|
||||
retryDelayMinutes,
|
||||
inputSource,
|
||||
isEditMode,
|
||||
editFolder,
|
||||
resetState,
|
||||
onSaved,
|
||||
onClose,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setNameError(
|
||||
t("watchedFolders.modal.nameRequired", "Folder name is required"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (trimmedName.length > 50) {
|
||||
setNameError(
|
||||
t(
|
||||
"watchedFolders.modal.nameTooLong",
|
||||
"Folder name must be 50 characters or less",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setAutomationError("");
|
||||
setSaving(true);
|
||||
automationSaveTrigger.current?.();
|
||||
};
|
||||
|
||||
const title = isEditMode
|
||||
? t("watchedFolders.modal.editTitle", "Edit Watched Folder")
|
||||
: t("watchedFolders.modal.createTitle", "New Watched Folder");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={title}
|
||||
size="min(90rem, 94vw)"
|
||||
centered
|
||||
zIndex={Z_INDEX_AUTOMATE_MODAL}
|
||||
styles={{
|
||||
content: {
|
||||
height: "min(90vh, 60rem)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Body: two-column layout */}
|
||||
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
|
||||
{/* ── Left panel: folder config ── */}
|
||||
<div
|
||||
style={{
|
||||
width: "28rem",
|
||||
flexShrink: 0,
|
||||
borderRight: "0.0625rem solid var(--border-subtle)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ flex: 1, overflowY: "auto", padding: "1.25rem 1.5rem" }}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
{/* ── Identity ── */}
|
||||
<div>
|
||||
<SectionLabel>
|
||||
{t("watchedFolders.modal.sectionFolder", "Folder")}
|
||||
</SectionLabel>
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput
|
||||
placeholder={t(
|
||||
"watchedFolders.modal.namePlaceholder",
|
||||
"My Watched Folder",
|
||||
)}
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
const val = e.currentTarget.value;
|
||||
setName(val);
|
||||
setNameError("");
|
||||
if (!outputNameDirty.current) setOutputName(val);
|
||||
}}
|
||||
error={nameError}
|
||||
withAsterisk
|
||||
maxLength={50}
|
||||
style={{ flex: 1 }}
|
||||
size="sm"
|
||||
/>
|
||||
<IconSelector value={icon} onChange={setIcon} size="sm" />
|
||||
</Group>
|
||||
|
||||
<ColorInput
|
||||
label={t("watchedFolders.modal.color", "Accent colour")}
|
||||
value={accentColor}
|
||||
onChange={setAccentColor}
|
||||
format="hex"
|
||||
swatches={ACCENT_SWATCHES}
|
||||
size="sm"
|
||||
popoverProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_AUTOMATE_DROPDOWN,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* ── Source & Output ── */}
|
||||
<div>
|
||||
<SectionLabel>
|
||||
{t(
|
||||
"watchedFolders.modal.sectionSourceOutput",
|
||||
"Source & Output",
|
||||
)}
|
||||
</SectionLabel>
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t(
|
||||
"watchedFolders.modal.inputSource",
|
||||
"Input source",
|
||||
)}
|
||||
value={inputSource}
|
||||
onChange={(v) =>
|
||||
v &&
|
||||
setInputSource(
|
||||
v as NonNullable<WatchedFolder["inputSource"]>,
|
||||
)
|
||||
}
|
||||
data={[
|
||||
{
|
||||
value: "idb",
|
||||
label: t(
|
||||
"watchedFolders.modal.inputSourceBrowser",
|
||||
"Browser — drop files here",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "local-folder",
|
||||
label: canReadLocalFolder
|
||||
? t(
|
||||
"watchedFolders.modal.inputSourceLocal",
|
||||
"Local folder (auto-scan)",
|
||||
)
|
||||
: t(
|
||||
"watchedFolders.modal.inputSourceLocalUnsupported",
|
||||
"Local folder (auto-scan) — Chrome/Edge only",
|
||||
),
|
||||
disabled: !canReadLocalFolder,
|
||||
},
|
||||
]}
|
||||
size="sm"
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_AUTOMATE_DROPDOWN,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Local-folder input directory picker */}
|
||||
{inputSource === "local-folder" && (
|
||||
<Box
|
||||
style={{
|
||||
marginLeft: "0.75rem",
|
||||
paddingLeft: "0.75rem",
|
||||
borderLeft: "2px solid var(--border-subtle)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
border: `0.0625rem solid ${inputDirName ? "var(--mantine-color-green-filled)" : "var(--mantine-color-yellow-5)"}`,
|
||||
backgroundColor: inputDirName
|
||||
? "var(--mantine-color-green-light)"
|
||||
: "var(--mantine-color-yellow-light)",
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<FolderSpecialIcon
|
||||
style={{
|
||||
fontSize: "1rem",
|
||||
color: inputDirName
|
||||
? "var(--color-green-500)"
|
||||
: "var(--mantine-color-yellow-6)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Stack gap={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="xs" fw={500}>
|
||||
{t(
|
||||
"watchedFolders.modal.inputFolder",
|
||||
"Input folder",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{inputDirName ??
|
||||
t(
|
||||
"watchedFolders.modal.inputFolderNotChosen",
|
||||
"No folder chosen — required for auto-scan",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const handle = await (
|
||||
window as unknown as {
|
||||
showDirectoryPicker: (options?: {
|
||||
mode?: "read" | "readwrite";
|
||||
}) => Promise<FileSystemDirectoryHandle>;
|
||||
}
|
||||
).showDirectoryPicker({ mode: "read" });
|
||||
pendingInputDirHandle.current = handle;
|
||||
setInputDirName(handle.name);
|
||||
} catch {
|
||||
/* cancelled */
|
||||
}
|
||||
}}
|
||||
>
|
||||
{inputDirName
|
||||
? t("watchedFolders.modal.changeFolder", "Change")
|
||||
: t(
|
||||
"watchedFolders.modal.chooseFolder",
|
||||
"Choose",
|
||||
)}
|
||||
</Button>
|
||||
{inputDirName && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
pendingInputDirHandle.current = null;
|
||||
setInputDirName(null);
|
||||
}}
|
||||
>
|
||||
{t("watchedFolders.modal.clearFolder", "Clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
<Text size="xs" c="dimmed" mt={6}>
|
||||
{t(
|
||||
"watchedFolders.modal.autoScanHelp",
|
||||
"New PDF files in this folder are processed automatically every 10 seconds.",
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Local output folder */}
|
||||
<Box
|
||||
style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
border: `0.0625rem solid ${outputDirName ? "var(--mantine-color-green-filled)" : "var(--border-subtle)"}`,
|
||||
backgroundColor: outputDirName
|
||||
? "var(--mantine-color-green-light)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<FolderSpecialIcon
|
||||
style={{
|
||||
fontSize: "1rem",
|
||||
color: outputDirName
|
||||
? "var(--color-green-500)"
|
||||
: "var(--mantine-color-dimmed)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Stack gap={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="xs" fw={500}>
|
||||
{t(
|
||||
"watchedFolders.modal.localOutputFolder",
|
||||
"Local output folder",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{!canWriteLocalFolder
|
||||
? t(
|
||||
"watchedFolders.modal.outputFolderUnsupported",
|
||||
"Not supported in this browser",
|
||||
)
|
||||
: (outputDirName ??
|
||||
t(
|
||||
"watchedFolders.modal.outputFolderNotSet",
|
||||
"Not set — outputs stay in app",
|
||||
))}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Tooltip
|
||||
label={FS_WRITE_UNSUPPORTED_MSG}
|
||||
disabled={canWriteLocalFolder}
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_AUTOMATE_DROPDOWN}
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
disabled={!canWriteLocalFolder}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const handle = await (
|
||||
window as unknown as {
|
||||
showDirectoryPicker: (options?: {
|
||||
mode?: "read" | "readwrite";
|
||||
}) => Promise<FileSystemDirectoryHandle>;
|
||||
}
|
||||
).showDirectoryPicker({ mode: "readwrite" });
|
||||
pendingDirHandle.current = handle;
|
||||
setOutputDirName(handle.name);
|
||||
} catch {
|
||||
/* cancelled */
|
||||
}
|
||||
}}
|
||||
>
|
||||
{outputDirName
|
||||
? t("watchedFolders.modal.changeFolder", "Change")
|
||||
: t("watchedFolders.modal.chooseFolder", "Choose")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{outputDirName && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
pendingDirHandle.current = null;
|
||||
setOutputDirName(null);
|
||||
}}
|
||||
>
|
||||
{t("watchedFolders.modal.clearFolder", "Clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* ── Advanced (collapsible) ── */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.35rem",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: "0.25rem 0",
|
||||
width: "100%",
|
||||
color: "var(--tool-subcategory-text-color)",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
sx={{
|
||||
fontSize: "1rem",
|
||||
transform: showAdvanced ? "rotate(90deg)" : "none",
|
||||
transition: "transform 160ms ease",
|
||||
}}
|
||||
/>
|
||||
{t("watchedFolders.modal.advanced", "Advanced")}
|
||||
</button>
|
||||
|
||||
<Collapse in={showAdvanced} transitionDuration={180}>
|
||||
<Stack gap="sm" mt="sm">
|
||||
{/* Replace original */}
|
||||
<Switch
|
||||
label={t(
|
||||
"watchedFolders.modal.replaceOriginal",
|
||||
"Replace original file",
|
||||
)}
|
||||
description={
|
||||
outputMode === "new_version"
|
||||
? t(
|
||||
"watchedFolders.modal.outputModeVersionDesc",
|
||||
"Output replaces input as a new version",
|
||||
)
|
||||
: t(
|
||||
"watchedFolders.modal.outputModeNewDesc",
|
||||
"Output saved as a separate new file",
|
||||
)
|
||||
}
|
||||
checked={outputMode === "new_version"}
|
||||
onChange={(e) =>
|
||||
setOutputMode(
|
||||
e.currentTarget.checked ? "new_version" : "new_file",
|
||||
)
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{/* Filename prefix / suffix */}
|
||||
<Box
|
||||
style={{
|
||||
opacity: outputMode === "new_version" ? 0.4 : 1,
|
||||
pointerEvents:
|
||||
outputMode === "new_version" ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" align="flex-end">
|
||||
{outputNamePosition === "auto-number" ? (
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="xs" fw={500} mb={4}>
|
||||
{t(
|
||||
"watchedFolders.modal.autoNumber",
|
||||
"Auto-number",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"watchedFolders.modal.autoNumberExample",
|
||||
"e.g. document.pdf → document (1).pdf",
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<TextInput
|
||||
label={
|
||||
outputNamePosition === "suffix"
|
||||
? t(
|
||||
"watchedFolders.modal.outputNameSuffix",
|
||||
"Filename suffix",
|
||||
)
|
||||
: t(
|
||||
"watchedFolders.modal.outputNamePrefix",
|
||||
"Filename prefix",
|
||||
)
|
||||
}
|
||||
value={outputName}
|
||||
onChange={(e) => {
|
||||
outputNameDirty.current = true;
|
||||
setOutputName(e.currentTarget.value);
|
||||
}}
|
||||
maxLength={100}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
size="xs"
|
||||
value={outputNamePosition}
|
||||
onChange={(v) =>
|
||||
v &&
|
||||
setOutputNamePosition(
|
||||
v as "prefix" | "suffix" | "auto-number",
|
||||
)
|
||||
}
|
||||
data={[
|
||||
{
|
||||
value: "prefix",
|
||||
label: t(
|
||||
"watchedFolders.modal.positionPrefix",
|
||||
"Prefix",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "suffix",
|
||||
label: t(
|
||||
"watchedFolders.modal.positionSuffix",
|
||||
"Suffix",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "auto-number",
|
||||
label: t(
|
||||
"watchedFolders.modal.autoNumber",
|
||||
"Auto-number",
|
||||
),
|
||||
},
|
||||
]}
|
||||
style={{ width: "8rem", flexShrink: 0 }}
|
||||
mb={4}
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_AUTOMATE_DROPDOWN,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* Retry settings */}
|
||||
<Group gap="sm" grow>
|
||||
<NumberInput
|
||||
label={t(
|
||||
"watchedFolders.modal.maxRetries",
|
||||
"Max auto retries",
|
||||
)}
|
||||
value={maxRetries}
|
||||
onChange={(v) =>
|
||||
setMaxRetries(
|
||||
typeof v === "number"
|
||||
? Math.max(0, Math.min(10, v))
|
||||
: 0,
|
||||
)
|
||||
}
|
||||
min={0}
|
||||
max={10}
|
||||
size="sm"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t(
|
||||
"watchedFolders.modal.retryDelay",
|
||||
"Retry interval (min)",
|
||||
)}
|
||||
value={retryDelayMinutes}
|
||||
onChange={(v) =>
|
||||
setRetryDelayMinutes(
|
||||
typeof v === "number"
|
||||
? Math.max(1, Math.min(60, v))
|
||||
: 5,
|
||||
)
|
||||
}
|
||||
min={1}
|
||||
max={60}
|
||||
size="sm"
|
||||
disabled={maxRetries === 0}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem 1.5rem",
|
||||
borderTop: "0.0625rem solid var(--border-subtle)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{saveError && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
onClose={() => setSaveError(null)}
|
||||
withCloseButton
|
||||
mb="sm"
|
||||
>
|
||||
{saveError}
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={handleClose}
|
||||
>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!name.trim()}
|
||||
>
|
||||
{isEditMode
|
||||
? t("watchedFolders.modal.saveChanges", "Save changes")
|
||||
: t("watchedFolders.modal.createFolder", "Create folder")}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right panel: automation steps ── */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "1rem 1.5rem 0.5rem", flexShrink: 0 }}>
|
||||
<SectionLabel>
|
||||
{t("watchedFolders.modal.sectionSteps", "Steps")}
|
||||
</SectionLabel>
|
||||
{automationError && (
|
||||
<Text size="xs" c="red" mt={4}>
|
||||
{automationError}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
padding: "0 1.5rem 1.5rem",
|
||||
}}
|
||||
>
|
||||
<AutomationCreation
|
||||
mode={isEditMode ? AutomationMode.EDIT : AutomationMode.CREATE}
|
||||
existingAutomation={existingAutomation ?? undefined}
|
||||
onBack={handleClose}
|
||||
onComplete={handleAutomationComplete}
|
||||
onSaveFailed={() => {
|
||||
setSaving(false);
|
||||
setAutomationError(
|
||||
t(
|
||||
"watchedFolders.modal.automationRequired",
|
||||
"Add at least one configured step before saving.",
|
||||
),
|
||||
);
|
||||
}}
|
||||
toolRegistry={toolRegistry}
|
||||
hideMetadata
|
||||
nameOverride={
|
||||
name.trim() ||
|
||||
t(
|
||||
"watchedFolders.modal.automationNameFallback",
|
||||
"Watched Folder Automation",
|
||||
)
|
||||
}
|
||||
saveTriggerRef={automationSaveTrigger}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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 { useWatchedFolders } from "@app/hooks/useWatchedFolders";
|
||||
import { useFolderRunStatuses } from "@app/hooks/useFolderRunStatuses";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { WatchedFolderManagementModal } from "@app/components/watchedFolders/WatchedFolderManagementModal";
|
||||
import { DeleteFolderConfirmModal } from "@app/components/watchedFolders/DeleteFolderConfirmModal";
|
||||
import { WatchedFolderCard } from "@app/components/watchedFolders/WatchedFolderCard";
|
||||
import { WatchedFolder } from "@app/types/watchedFolders";
|
||||
import { AutomationConfig } from "@app/types/automation";
|
||||
import { automationStorage } from "@app/services/automationStorage";
|
||||
import {
|
||||
WATCHED_FOLDER_VIEW_ID,
|
||||
WATCHED_FOLDER_WORKBENCH_ID,
|
||||
} from "@app/components/watchedFolders/WatchedFoldersRegistration";
|
||||
|
||||
export function WatchedFolderSection() {
|
||||
const { t } = useTranslation();
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editFolder, setEditFolder] = useState<WatchedFolder | null>(null);
|
||||
const [editAutomation, setEditAutomation] = useState<AutomationConfig | null>(
|
||||
null,
|
||||
);
|
||||
const [deleteTarget, setDeleteTarget] = useState<WatchedFolder | null>(null);
|
||||
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
|
||||
|
||||
const { folders, loading, deleteFolder, refreshFolders } =
|
||||
useWatchedFolders();
|
||||
const statuses = useFolderRunStatuses(folders);
|
||||
|
||||
const { setCustomWorkbenchViewData } = useToolWorkflow();
|
||||
const { actions } = useNavigationActions();
|
||||
|
||||
const handleFolderClick = (folderId: string) => {
|
||||
setActiveFolderId(folderId);
|
||||
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId });
|
||||
actions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
|
||||
};
|
||||
|
||||
const handleEditFolder = async (
|
||||
e: React.MouseEvent,
|
||||
folder: WatchedFolder,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
setEditFolder(folder);
|
||||
const automation = await automationStorage.getAutomation(
|
||||
folder.automationId,
|
||||
);
|
||||
setEditAutomation(automation);
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent, folder: WatchedFolder) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(folder);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
if (activeFolderId === deleteTarget.id) {
|
||||
setActiveFolderId(null);
|
||||
actions.setWorkbench("fileEditor");
|
||||
}
|
||||
await deleteFolder(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setCreateModalOpen(false);
|
||||
setEditFolder(null);
|
||||
setEditAutomation(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
borderTop: "1px solid var(--border-subtle)",
|
||||
backgroundColor: "var(--bg-toolbar)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box px="sm" pt="xs" pb="2px" style={{ flexShrink: 0 }}>
|
||||
<Box className="tool-subcategory-row">
|
||||
<Text
|
||||
className="tool-subcategory-row-title"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => {
|
||||
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, {
|
||||
folderId: null,
|
||||
});
|
||||
actions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
|
||||
}}
|
||||
>
|
||||
{t("watchedFolders.title", "Watched Folders")}
|
||||
</Text>
|
||||
<Box className="tool-subcategory-row-rule" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<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("watchedFolders.noFolders", "No watch folders yet")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{folders.map((folder) => (
|
||||
<WatchedFolderCard
|
||||
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)}
|
||||
onFileDrop={(fileIds) => {
|
||||
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, {
|
||||
folderId: folder.id,
|
||||
pendingFileIds: fileIds,
|
||||
});
|
||||
actions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
|
||||
setActiveFolderId(folder.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
className="tool-button"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
px="sm"
|
||||
leftSection={
|
||||
<AddIcon
|
||||
style={{ fontSize: 14, color: "var(--mantine-color-gray-5)" }}
|
||||
/>
|
||||
}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("watchedFolders.newFolder", "New folder")}
|
||||
</Text>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<WatchedFolderManagementModal
|
||||
opened={createModalOpen}
|
||||
editFolder={editFolder}
|
||||
existingAutomation={editAutomation}
|
||||
onClose={handleModalClose}
|
||||
onSaved={refreshFolders}
|
||||
/>
|
||||
<DeleteFolderConfirmModal
|
||||
opened={!!deleteTarget}
|
||||
folder={deleteTarget}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+2442
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,254 @@
|
||||
/* Watched Folders home-page card grid.
|
||||
*
|
||||
* Mirrors the files-page card vocabulary (see filesPage/FilesPage.css) but
|
||||
* with its own `.wf-*` namespace so the two surfaces can evolve
|
||||
* independently. Values are intentionally copied rather than shared. */
|
||||
|
||||
@keyframes wf-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.wf-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.wf-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 0.85rem;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
transition:
|
||||
transform 0.16s cubic-bezier(0.32, 0.72, 0.32, 1),
|
||||
border-color 0.14s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.wf-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--accent-interactive, #6366f1) 35%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.08),
|
||||
0 8px 24px -8px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.wf-card:focus-visible {
|
||||
outline: 2px solid var(--accent-interactive, #6366f1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wf-card.is-drop-target {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--accent-interactive, #6366f1) 10%,
|
||||
var(--bg-surface)
|
||||
);
|
||||
box-shadow: 0 0 0 2px var(--accent-interactive, #6366f1);
|
||||
border-color: var(--accent-interactive, #6366f1);
|
||||
}
|
||||
|
||||
/* Dragging a file the folder already contains — signal it's a no-op, not an add. */
|
||||
.wf-card.is-already-member {
|
||||
background: color-mix(in srgb, #f59e0b 8%, var(--bg-surface));
|
||||
box-shadow: 0 0 0 2px #f59e0b;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.wf-card-drag-message {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--bg-surface) 72%, transparent);
|
||||
backdrop-filter: blur(1px);
|
||||
border-radius: inherit;
|
||||
/* Let drag/drop events pass through to the card underneath. */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wf-card-thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--text-muted) 5%, var(--bg-surface)),
|
||||
var(--bg-surface)
|
||||
);
|
||||
aspect-ratio: 1.6 / 1;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.wf-card-body {
|
||||
padding: 0.65rem 0.85rem 0.7rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wf-card-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.wf-card-meta {
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.wf-card-status-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wf-card-status-dot.is-pulsing {
|
||||
animation: wf-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wf-card-actions {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
right: 0.4rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.wf-card:hover .wf-card-actions,
|
||||
.wf-card:focus-within .wf-card-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* "New folder" tile - a dashed call-to-action that sits in the grid
|
||||
alongside the real folder cards. */
|
||||
.wf-new-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
background: var(--bg-surface);
|
||||
border: 1.5px dashed var(--border-subtle);
|
||||
border-radius: 0.85rem;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
min-height: 9rem;
|
||||
transition:
|
||||
border-color 0.14s ease,
|
||||
color 0.14s ease,
|
||||
background-color 0.14s ease;
|
||||
}
|
||||
|
||||
.wf-new-tile:hover {
|
||||
border-color: var(--accent-interactive, #6366f1);
|
||||
color: var(--accent-interactive, #6366f1);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--accent-interactive, #6366f1) 6%,
|
||||
var(--bg-surface)
|
||||
);
|
||||
}
|
||||
|
||||
.wf-new-tile-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--accent-interactive, #6366f1) 12%,
|
||||
transparent
|
||||
);
|
||||
color: var(--accent-interactive, #6366f1);
|
||||
}
|
||||
|
||||
.wf-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 4rem 1.5rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wf-empty-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--accent-interactive, #6366f1) 12%,
|
||||
transparent
|
||||
);
|
||||
color: var(--accent-interactive, #6366f1);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.wf-empty-title {
|
||||
font-size: 1.15rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.wf-empty-hint {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 30rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wf-card,
|
||||
.wf-card:hover {
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { WatchedFolderWorkbenchView } from "@app/components/watchedFolders/WatchedFolderWorkbenchView";
|
||||
import { seedDefaultFolders } from "@app/data/watchedFolderPresets";
|
||||
import { useWatchedFolderUrlSync } from "@app/hooks/useWatchedFolderUrlSync";
|
||||
|
||||
export const WATCHED_FOLDER_VIEW_ID = "watchedFolder";
|
||||
export const WATCHED_FOLDER_WORKBENCH_ID = "custom:watchedFolder" as const;
|
||||
|
||||
export default function WatchedFoldersRegistration() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
clearCustomWorkbenchViewData,
|
||||
} = useToolWorkflow();
|
||||
useWatchedFolderUrlSync();
|
||||
|
||||
// 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: WATCHED_FOLDER_VIEW_ID,
|
||||
workbenchId: WATCHED_FOLDER_WORKBENCH_ID,
|
||||
label: t("watchedFolders.sidebarTitle", "Watched Folders"),
|
||||
component: WatchedFolderWorkbenchView,
|
||||
// Show the standard workbench bar (view switcher) so users can navigate
|
||||
// back to Viewer / Active Files instead of being stranded in this view.
|
||||
hideTopControls: false,
|
||||
hideToolPanel: true,
|
||||
});
|
||||
return () => {
|
||||
clearRef.current(WATCHED_FOLDER_VIEW_ID);
|
||||
unregisterRef.current(WATCHED_FOLDER_VIEW_ID);
|
||||
};
|
||||
}, [registerCustomWorkbenchView]);
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user