- Auto-sign-in after registration instead of redirect to login - Email verification system with token generation, send/confirm API routes, and persistent banner - 7-step onboarding wizard (org, location, services, upload source, AI, integrations, complete) - Middleware redirects owners with incomplete onboarding to /onboarding - Integration provider plugin architecture with registry and 6 providers (Planning Center, Monday.com, Airtable, Google Sheets, Webhook, CSV Export) - Full integration CRUD API with test, sync, fields, and OAuth authorize/callback routes - Refactored fireIntegrationEvent to use Integration model with legacy AppSettings fallback - Migration script for existing Monday.com/webhook config to Integration rows - Settings page restructured from monolithic 1290-line file into focused sub-routes with section navigation - Integration hub UI with provider tiles, connect flow, and individual config pages - Post-onboarding contextual guidance cards on dashboard with dismissible hints - Schema: Integration model, onboardingComplete/onboardingStep on Organization, dismissedHints on OrgMember Made-with: Cursor
280 lines
8.2 KiB
TypeScript
280 lines
8.2 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { toast } from "sonner";
|
|
import { useTheme } from "next-themes";
|
|
import {
|
|
Loader2,
|
|
Save,
|
|
Sun,
|
|
Moon,
|
|
Monitor,
|
|
Bell,
|
|
HardDrive,
|
|
Trash2,
|
|
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 {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardDescription,
|
|
} from "@/components/ui/card";
|
|
|
|
const NOTIFICATION_STORAGE_KEY = "echo-ocr-notification-prefs";
|
|
|
|
type NotificationPrefs = {
|
|
processingComplete: boolean;
|
|
processingErrors: boolean;
|
|
folderWatchAlerts: boolean;
|
|
cleanupReminders: boolean;
|
|
};
|
|
|
|
const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
|
|
processingComplete: true,
|
|
processingErrors: true,
|
|
folderWatchAlerts: true,
|
|
cleanupReminders: false,
|
|
};
|
|
|
|
function loadNotificationPrefs(): NotificationPrefs {
|
|
if (typeof window === "undefined") return DEFAULT_NOTIFICATION_PREFS;
|
|
try {
|
|
const raw = localStorage.getItem(NOTIFICATION_STORAGE_KEY);
|
|
if (raw) return { ...DEFAULT_NOTIFICATION_PREFS, ...JSON.parse(raw) };
|
|
} catch {}
|
|
return DEFAULT_NOTIFICATION_PREFS;
|
|
}
|
|
|
|
export default function GeneralSettingsPage() {
|
|
const { theme, setTheme } = useTheme();
|
|
const [sourceRetention, setSourceRetention] = React.useState(30);
|
|
const [imageRetention, setImageRetention] = React.useState(180);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [saving, setSaving] = React.useState(false);
|
|
const [cleanupStatus, setCleanupStatus] = React.useState<{
|
|
sourcesEligible: number;
|
|
imagesEligible: number;
|
|
} | null>(null);
|
|
const [cleaning, setCleaning] = React.useState(false);
|
|
const [notifPrefs, setNotifPrefs] = React.useState<NotificationPrefs>(
|
|
DEFAULT_NOTIFICATION_PREFS
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
setNotifPrefs(loadNotificationPrefs());
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
fetch("/api/settings")
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
setSourceRetention(data.sourceRetentionDays || 30);
|
|
setImageRetention(data.imageRetentionDays || 180);
|
|
setLoading(false);
|
|
})
|
|
.catch(() => setLoading(false));
|
|
|
|
fetch("/api/cleanup")
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
if (data.sourcesEligible !== undefined) setCleanupStatus(data);
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const updateNotifPref = (key: keyof NotificationPrefs, value: boolean) => {
|
|
setNotifPrefs((prev) => {
|
|
const next = { ...prev, [key]: value };
|
|
try {
|
|
localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(next));
|
|
} catch {}
|
|
return next;
|
|
});
|
|
toast.success("Notification preference updated");
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetch("/api/settings", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
sourceRetentionDays: sourceRetention,
|
|
imageRetentionDays: imageRetention,
|
|
}),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
toast.success("Settings saved");
|
|
} catch {
|
|
toast.error("Failed to save settings");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const runCleanup = async () => {
|
|
setCleaning(true);
|
|
try {
|
|
const res = await fetch("/api/cleanup", { method: "POST" });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
toast.success(
|
|
`Cleaned ${data.sourcesDeleted ?? 0} sources and ${data.imagesDeleted ?? 0} images`
|
|
);
|
|
setCleanupStatus({ sourcesEligible: 0, imagesEligible: 0 });
|
|
}
|
|
} catch {
|
|
toast.error("Cleanup failed");
|
|
} finally {
|
|
setCleaning(false);
|
|
}
|
|
};
|
|
|
|
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">
|
|
{/* Appearance */}
|
|
<Card className="glass-card">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Sun className="size-4" />
|
|
Appearance
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex gap-2">
|
|
{[
|
|
{ value: "light", icon: Sun, label: "Light" },
|
|
{ value: "dark", icon: Moon, label: "Dark" },
|
|
{ value: "system", icon: Monitor, label: "System" },
|
|
].map((opt) => (
|
|
<Button
|
|
key={opt.value}
|
|
variant={theme === opt.value ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setTheme(opt.value)}
|
|
className="gap-1.5"
|
|
>
|
|
<opt.icon className="size-3.5" />
|
|
{opt.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Notifications */}
|
|
<Card className="glass-card">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Bell className="size-4" />
|
|
Notifications
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{(
|
|
[
|
|
["processingComplete", "Processing Complete"],
|
|
["processingErrors", "Processing Errors"],
|
|
["folderWatchAlerts", "Folder Watch Alerts"],
|
|
["cleanupReminders", "Cleanup Reminders"],
|
|
] as const
|
|
).map(([key, label]) => (
|
|
<div key={key} className="flex items-center justify-between">
|
|
<Label className="text-sm">{label}</Label>
|
|
<Switch
|
|
checked={notifPrefs[key]}
|
|
onCheckedChange={(v) => updateNotifPref(key, v)}
|
|
/>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Storage & Retention */}
|
|
<Card className="glass-card">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<HardDrive className="size-4" />
|
|
Storage & Retention
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Automatically clean up old files to save storage.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label>Source file retention (days)</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
value={sourceRetention}
|
|
onChange={(e) =>
|
|
setSourceRetention(parseInt(e.target.value) || 30)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Image retention (days)</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
value={imageRetention}
|
|
onChange={(e) =>
|
|
setImageRetention(parseInt(e.target.value) || 180)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{cleanupStatus && (
|
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
|
<span>
|
|
{cleanupStatus.sourcesEligible} sources,{" "}
|
|
{cleanupStatus.imagesEligible} images eligible for cleanup
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={runCleanup}
|
|
disabled={cleaning}
|
|
>
|
|
{cleaning ? (
|
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
|
) : (
|
|
<Trash2 className="mr-1.5 size-3.5" />
|
|
)}
|
|
Run Cleanup
|
|
</Button>
|
|
</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>
|
|
);
|
|
}
|