echos-ocr/src/app/(dashboard)/settings/upload-sources/page.tsx

561 lines
17 KiB
TypeScript
Raw Normal View History

"use client";
import * as React from "react";
import { toast } from "sonner";
import {
Loader2,
Save,
Mail,
FolderSearch,
Server,
Wifi,
WifiOff,
Check,
RefreshCw,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type SourceSettings = {
emailImapHost: string;
emailImapPort: number;
emailImapUser: string;
emailImapPass: string;
emailImapTls: boolean;
emailFolder: string;
emailWatching: boolean;
emailProcessed: string;
emailProcessedFolder: string;
ftpEnabled: boolean;
ftpHost: string;
ftpPort: number;
ftpUser: string;
ftpPass: string;
ftpTls: boolean;
ftpIncomingDir: string;
ftpProcessedDir: string;
watchDir: string;
watching: boolean;
};
export default function UploadSourcesPage() {
const [settings, setSettings] = React.useState<SourceSettings>({
emailImapHost: "",
emailImapPort: 993,
emailImapUser: "",
emailImapPass: "",
emailImapTls: true,
emailFolder: "INBOX",
emailWatching: false,
emailProcessed: "mark_read",
emailProcessedFolder: "Processed",
ftpEnabled: false,
ftpHost: "",
ftpPort: 21,
ftpUser: "",
ftpPass: "",
ftpTls: true,
ftpIncomingDir: "/incoming",
ftpProcessedDir: "/processed",
watchDir: "",
watching: false,
});
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [emailTestStatus, setEmailTestStatus] = React.useState<
"idle" | "testing" | "success" | "error"
>("idle");
const [ftpTestStatus, setFtpTestStatus] = React.useState<
"idle" | "testing" | "success" | "error"
>("idle");
const [scanning, setScanning] = React.useState(false);
React.useEffect(() => {
fetch("/api/settings")
.then((r) => r.json())
.then((data) => {
setSettings({
emailImapHost: data.emailImapHost || "",
emailImapPort: data.emailImapPort || 993,
emailImapUser: data.emailImapUser || "",
emailImapPass: data.emailImapPass || "",
emailImapTls: data.emailImapTls ?? true,
emailFolder: data.emailFolder || "INBOX",
emailWatching: data.emailWatching || false,
emailProcessed: data.emailProcessed || "mark_read",
emailProcessedFolder: data.emailProcessedFolder || "Processed",
ftpEnabled: data.ftpEnabled || false,
ftpHost: data.ftpHost || "",
ftpPort: data.ftpPort || 21,
ftpUser: data.ftpUser || "",
ftpPass: data.ftpPass || "",
ftpTls: data.ftpTls ?? true,
ftpIncomingDir: data.ftpIncomingDir || "/incoming",
ftpProcessedDir: data.ftpProcessedDir || "/processed",
watchDir: data.watchDir || "",
watching: data.watching || false,
});
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const handleSave = async () => {
setSaving(true);
try {
const payload = { ...settings } as Record<string, unknown>;
if (payload.emailImapPass === "••••••••") delete payload.emailImapPass;
if (payload.ftpPass === "••••••••") delete payload.ftpPass;
const res = await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error();
toast.success("Upload source settings saved");
} catch {
toast.error("Failed to save settings");
} finally {
setSaving(false);
}
};
const testEmail = async () => {
setEmailTestStatus("testing");
try {
const res = await fetch("/api/email-watch/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
host: settings.emailImapHost,
port: settings.emailImapPort,
user: settings.emailImapUser,
pass: settings.emailImapPass,
tls: settings.emailImapTls,
}),
});
const data = await res.json();
setEmailTestStatus(res.ok ? "success" : "error");
if (res.ok) toast.success(data.message || "Email connection successful");
else toast.error(data.error || "Email connection failed");
} catch {
setEmailTestStatus("error");
toast.error("Email test failed");
}
};
const scanNow = async () => {
setScanning(true);
try {
const res = await fetch("/api/email-watch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "scan" }),
});
if (res.ok) {
const data = await res.json();
toast.success(`Scanned ${data.processed || 0} emails`);
} else {
const data = await res.json().catch(() => ({}));
toast.error(data.error || "Scan failed");
}
} catch {
toast.error("Scan failed");
} finally {
setScanning(false);
}
};
const testFtp = async () => {
setFtpTestStatus("testing");
try {
const res = await fetch("/api/ftp-watch/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
host: settings.ftpHost,
port: settings.ftpPort,
user: settings.ftpUser,
pass: settings.ftpPass,
tls: settings.ftpTls,
incomingDir: settings.ftpIncomingDir,
}),
});
const data = await res.json();
setFtpTestStatus(res.ok && data.ok ? "success" : "error");
if (res.ok && data.ok) {
toast.success(`FTP connected — ${data.files ?? 0} file(s) found`);
} else {
toast.error(data.error || "FTP connection failed");
}
} catch {
setFtpTestStatus("error");
toast.error("FTP test failed");
}
};
const toggleFolderWatch = async () => {
const endpoint = settings.watching ? "/api/watch/stop" : "/api/watch/start";
try {
const res = await fetch(endpoint, { method: "POST" });
if (res.ok) {
setSettings((s) => ({ ...s, watching: !s.watching }));
toast.success(
settings.watching ? "Folder watch stopped" : "Folder watch started"
);
}
} catch {
toast.error("Failed to toggle folder watch");
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
{/* Email Inbox */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Mail className="size-4" />
Email Inbox
</CardTitle>
<CardDescription>
Auto-import response cards from an email inbox via IMAP.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>IMAP Host</Label>
<Input
value={settings.emailImapHost}
onChange={(e) =>
setSettings((s) => ({ ...s, emailImapHost: e.target.value }))
}
placeholder="imap.gmail.com"
/>
</div>
<div className="space-y-2">
<Label>Port</Label>
<Input
type="number"
value={settings.emailImapPort}
onChange={(e) =>
setSettings((s) => ({
...s,
emailImapPort: parseInt(e.target.value) || 993,
}))
}
/>
</div>
<div className="space-y-2">
<Label>Username</Label>
<Input
value={settings.emailImapUser}
onChange={(e) =>
setSettings((s) => ({ ...s, emailImapUser: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Password</Label>
<Input
type="password"
value={settings.emailImapPass}
onChange={(e) =>
setSettings((s) => ({ ...s, emailImapPass: e.target.value }))
}
/>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={settings.emailImapTls}
onCheckedChange={(v) =>
setSettings((s) => ({ ...s, emailImapTls: v }))
}
/>
<Label className="text-sm">Use TLS</Label>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Folder</Label>
<Input
value={settings.emailFolder}
onChange={(e) =>
setSettings((s) => ({ ...s, emailFolder: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>After Processing</Label>
<Select
value={settings.emailProcessed}
onValueChange={(v) =>
v && setSettings((s) => ({ ...s, emailProcessed: v }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mark_read">Mark as read</SelectItem>
<SelectItem value="move">Move to folder</SelectItem>
<SelectItem value="delete">Delete</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={testEmail}>
{emailTestStatus === "testing" ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
) : emailTestStatus === "success" ? (
<Check className="mr-1.5 size-3.5 text-emerald-500" />
) : (
<Mail className="mr-1.5 size-3.5" />
)}
Test Connection
</Button>
<Button variant="outline" size="sm" onClick={scanNow} disabled={scanning}>
{scanning ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1.5 size-3.5" />
)}
Scan Now
</Button>
<div className="flex items-center gap-2">
<Switch
checked={settings.emailWatching}
onCheckedChange={(v) =>
setSettings((s) => ({ ...s, emailWatching: v }))
}
/>
<Label className="text-sm">Auto-watch</Label>
</div>
</div>
</CardContent>
</Card>
{/* FTP */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Server className="size-4" />
FTP Server
</CardTitle>
<CardDescription>
Poll an FTP server for new scanned files. Files are downloaded,
processed, then moved to a &ldquo;processed&rdquo; directory.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={settings.ftpEnabled}
onCheckedChange={(v) =>
setSettings((s) => ({ ...s, ftpEnabled: !!v }))
}
/>
<Label className="text-sm">Enable FTP watcher</Label>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>FTP Host</Label>
<Input
value={settings.ftpHost}
onChange={(e) =>
setSettings((s) => ({ ...s, ftpHost: e.target.value }))
}
placeholder="ftp.example.com"
/>
</div>
<div className="space-y-2">
<Label>Port</Label>
<Input
type="number"
value={settings.ftpPort}
onChange={(e) =>
setSettings((s) => ({
...s,
ftpPort: parseInt(e.target.value) || 21,
}))
}
/>
</div>
<div className="space-y-2">
<Label>Username</Label>
<Input
value={settings.ftpUser}
onChange={(e) =>
setSettings((s) => ({ ...s, ftpUser: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Password</Label>
<Input
type="password"
value={settings.ftpPass}
onChange={(e) =>
setSettings((s) => ({ ...s, ftpPass: e.target.value }))
}
/>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={settings.ftpTls}
onCheckedChange={(v) =>
setSettings((s) => ({ ...s, ftpTls: !!v }))
}
/>
<Label className="text-sm">Use TLS</Label>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Incoming Directory</Label>
<Input
value={settings.ftpIncomingDir}
onChange={(e) =>
setSettings((s) => ({ ...s, ftpIncomingDir: e.target.value }))
}
placeholder="/incoming"
/>
</div>
<div className="space-y-2">
<Label>Processed Directory</Label>
<Input
value={settings.ftpProcessedDir}
onChange={(e) =>
setSettings((s) => ({
...s,
ftpProcessedDir: e.target.value,
}))
}
placeholder="/processed"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={testFtp}
disabled={!settings.ftpHost || !settings.ftpUser}
>
{ftpTestStatus === "testing" ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
) : ftpTestStatus === "success" ? (
<Check className="mr-1.5 size-3.5 text-emerald-500" />
) : (
<Server className="mr-1.5 size-3.5" />
)}
Test Connection
</Button>
</div>
</CardContent>
</Card>
{/* Folder Watch */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FolderSearch className="size-4" />
Folder Watch
</CardTitle>
<CardDescription>
Monitor a local directory for new scanned files.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Watch Directory</Label>
<Input
value={settings.watchDir}
onChange={(e) =>
setSettings((s) => ({ ...s, watchDir: e.target.value }))
}
placeholder="/mnt/scans"
/>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={toggleFolderWatch}
>
{settings.watching ? (
<>
<WifiOff className="mr-1.5 size-3.5" />
Stop Watching
</>
) : (
<>
<Wifi className="mr-1.5 size-3.5" />
Start Watching
</>
)}
</Button>
{settings.watching && (
<Badge variant="outline" className="text-emerald-500">
Active
</Badge>
)}
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Save className="mr-2 size-4" />
)}
Save Changes
</Button>
</div>
</div>
);
}