import { useCallback, useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; import { Link2, UploadCloud, DownloadCloud, Loader2, Save, Check, Info, AlertTriangle, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { settingsApi } from "@/lib/api"; import type { RemoteSnapshotInfo, WebDavSyncSettings } from "@/types"; // ─── WebDAV service presets ───────────────────────────────── interface WebDavPreset { id: string; label: string; baseUrl: string; hint: string; matchPattern?: string; // substring match on URL } const WEBDAV_PRESETS: WebDavPreset[] = [ { id: "jianguoyun", label: "settings.webdavSync.presets.jianguoyun", baseUrl: "https://dav.jianguoyun.com/dav/", hint: "settings.webdavSync.presets.jianguoyunHint", matchPattern: "jianguoyun.com", }, { id: "nextcloud", label: "settings.webdavSync.presets.nextcloud", baseUrl: "https://your-server/remote.php/dav/files/USERNAME/", hint: "settings.webdavSync.presets.nextcloudHint", matchPattern: "remote.php/dav", }, { id: "synology", label: "settings.webdavSync.presets.synology", baseUrl: "http://your-nas-ip:5005/", hint: "settings.webdavSync.presets.synologyHint", matchPattern: ":5005", }, { id: "custom", label: "settings.webdavSync.presets.custom", baseUrl: "", hint: "", }, ]; /** Match a URL to one of the preset providers, or "custom". */ function detectPreset(url: string): string { if (!url) return "custom"; for (const preset of WEBDAV_PRESETS) { if (preset.matchPattern && url.includes(preset.matchPattern)) { return preset.id; } } return "custom"; } /** Format an RFC 3339 date string for display; falls back to raw string. */ function formatDate(rfc3339: string): string { const d = new Date(rfc3339); return Number.isNaN(d.getTime()) ? rfc3339 : d.toLocaleString(); } // ─── Types ────────────────────────────────────────────────── type ActionState = | "idle" | "testing" | "saving" | "uploading" | "downloading" | "fetching_remote"; type DialogType = "upload" | "download" | null; interface WebdavSyncSectionProps { config?: WebDavSyncSettings; } // ─── ActionButton ─────────────────────────────────────────── /** Reusable button with loading spinner. */ function ActionButton({ actionState, targetState, alsoActiveFor, icon: Icon, activeLabel, idleLabel, disabled, ...props }: { actionState: ActionState; targetState: ActionState; alsoActiveFor?: ActionState[]; icon: LucideIcon; activeLabel: ReactNode; idleLabel: ReactNode; } & Omit, "children">) { const isActive = actionState === targetState || (alsoActiveFor?.includes(actionState) ?? false); return ( ); } // ─── Main component ───────────────────────────────────────── export function WebdavSyncSection({ config }: WebdavSyncSectionProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const [actionState, setActionState] = useState("idle"); const [dirty, setDirty] = useState(false); const [passwordTouched, setPasswordTouched] = useState(false); const [justSaved, setJustSaved] = useState(false); const justSavedTimerRef = useRef | null>(null); // Local form state — credentials are only persisted on explicit "Save". const [form, setForm] = useState(() => ({ baseUrl: config?.baseUrl ?? "", username: config?.username ?? "", password: config?.password ?? "", remoteRoot: config?.remoteRoot ?? "cc-switch-sync", profile: config?.profile ?? "default", autoSync: config?.autoSync ?? false, })); // Preset selector — derived from initial URL, updated on user selection const [presetId, setPresetId] = useState(() => detectPreset(config?.baseUrl ?? ""), ); const activePreset = WEBDAV_PRESETS.find((p) => p.id === presetId); // Confirmation dialog state const [dialogType, setDialogType] = useState(null); const [remoteInfo, setRemoteInfo] = useState(null); const closeDialog = useCallback(() => { setDialogType(null); setRemoteInfo(null); }, []); // Cleanup justSaved timer on unmount useEffect(() => { return () => { if (justSavedTimerRef.current) clearTimeout(justSavedTimerRef.current); }; }, []); // Sync form when config is loaded/updated from backend, but not while user is editing useEffect(() => { if (!config || dirty) return; setForm({ baseUrl: config.baseUrl ?? "", username: config.username ?? "", password: config.password ?? "", remoteRoot: config.remoteRoot ?? "cc-switch-sync", profile: config.profile ?? "default", autoSync: config.autoSync ?? false, }); setPasswordTouched(false); setPresetId(detectPreset(config.baseUrl ?? "")); }, [config, dirty]); const updateField = useCallback((field: keyof typeof form, value: string) => { setForm((prev) => ({ ...prev, [field]: value })); if (field === "password") { setPasswordTouched(true); } setDirty(true); setJustSaved(false); if (justSavedTimerRef.current) { clearTimeout(justSavedTimerRef.current); justSavedTimerRef.current = null; } }, []); const handlePresetChange = useCallback((id: string) => { setPresetId(id); const preset = WEBDAV_PRESETS.find((p) => p.id === id); if (preset?.baseUrl) { setForm((prev) => ({ ...prev, baseUrl: preset.baseUrl })); setDirty(true); setJustSaved(false); if (justSavedTimerRef.current) { clearTimeout(justSavedTimerRef.current); justSavedTimerRef.current = null; } } }, []); // When user edits the URL, check if it still matches the current preset on blur const handleBaseUrlBlur = useCallback(() => { if (presetId === "custom") return; const detected = detectPreset(form.baseUrl); if (detected !== presetId) { setPresetId("custom"); } }, [form.baseUrl, presetId]); const handleAutoSyncChange = useCallback((checked: boolean) => { setForm((prev) => ({ ...prev, autoSync: checked })); setDirty(true); setJustSaved(false); if (justSavedTimerRef.current) { clearTimeout(justSavedTimerRef.current); justSavedTimerRef.current = null; } }, []); const buildSettings = useCallback((): WebDavSyncSettings | null => { const baseUrl = form.baseUrl.trim(); if (!baseUrl) return null; return { enabled: true, baseUrl, username: form.username.trim(), password: form.password, remoteRoot: form.remoteRoot.trim() || "cc-switch-sync", profile: form.profile.trim() || "default", autoSync: form.autoSync, }; }, [form]); // ─── Handlers ─────────────────────────────────────────── const handleTest = useCallback(async () => { const settings = buildSettings(); if (!settings) { toast.error(t("settings.webdavSync.missingUrl")); return; } setActionState("testing"); try { await settingsApi.webdavTestConnection(settings, !passwordTouched); toast.success(t("settings.webdavSync.testSuccess")); } catch (error) { toast.error( t("settings.webdavSync.testFailed", { error: (error as Error)?.message ?? String(error), }), ); } finally { setActionState("idle"); } }, [buildSettings, passwordTouched, t]); const handleSave = useCallback(async () => { const settings = buildSettings(); if (!settings) { toast.error(t("settings.webdavSync.missingUrl")); return; } setActionState("saving"); try { await settingsApi.webdavSyncSaveSettings(settings, passwordTouched); setDirty(false); setPasswordTouched(false); // Show "saved" indicator for 2 seconds setJustSaved(true); if (justSavedTimerRef.current) clearTimeout(justSavedTimerRef.current); justSavedTimerRef.current = setTimeout(() => { setJustSaved(false); justSavedTimerRef.current = null; }, 2000); await queryClient.invalidateQueries(); } catch (error) { toast.error( t("settings.webdavSync.saveFailed", { error: (error as Error)?.message ?? String(error), }), ); setActionState("idle"); return; } // Auto-test connection after save setActionState("testing"); try { await settingsApi.webdavTestConnection(settings, true); toast.success(t("settings.webdavSync.saveAndTestSuccess")); } catch (error) { toast.warning( t("settings.webdavSync.saveAndTestFailed", { error: (error as Error)?.message ?? String(error), }), ); } finally { setActionState("idle"); } }, [buildSettings, passwordTouched, queryClient, t]); /** Fetch remote info, then open upload confirmation dialog. */ const handleUploadClick = useCallback(async () => { if (dirty) { toast.error(t("settings.webdavSync.unsavedChanges")); return; } setActionState("fetching_remote"); try { const info = await settingsApi.webdavSyncFetchRemoteInfo(); if ("empty" in info) { setRemoteInfo(null); } else { setRemoteInfo(info); } setDialogType("upload"); } catch { setRemoteInfo(null); toast.error(t("settings.webdavSync.fetchRemoteFailed")); setActionState("idle"); return; } setActionState("idle"); }, [dirty, t]); /** Actually perform the upload after user confirms. */ const handleUploadConfirm = useCallback(async () => { if (dirty) { toast.error(t("settings.webdavSync.unsavedChanges")); return; } closeDialog(); setActionState("uploading"); try { await settingsApi.webdavSyncUpload(); toast.success(t("settings.webdavSync.uploadSuccess")); await queryClient.invalidateQueries(); } catch (error) { toast.error( t("settings.webdavSync.uploadFailed", { error: (error as Error)?.message ?? String(error), }), ); } finally { setActionState("idle"); } }, [closeDialog, dirty, queryClient, t]); /** Fetch remote info, then open download confirmation dialog. */ const handleDownloadClick = useCallback(async () => { if (dirty) { toast.error(t("settings.webdavSync.unsavedChanges")); return; } setActionState("fetching_remote"); try { const info = await settingsApi.webdavSyncFetchRemoteInfo(); if ("empty" in info) { toast.info(t("settings.webdavSync.noRemoteData")); return; } if (!info.compatible) { toast.error( t("settings.webdavSync.incompatibleVersion", { version: info.version, }), ); return; } setRemoteInfo(info); setDialogType("download"); } catch (error) { toast.error( t("settings.webdavSync.downloadFailed", { error: (error as Error)?.message ?? String(error), }), ); } finally { setActionState("idle"); } }, [dirty, t]); /** Actually perform the download after user confirms. */ const handleDownloadConfirm = useCallback(async () => { if (dirty) { toast.error(t("settings.webdavSync.unsavedChanges")); return; } closeDialog(); setActionState("downloading"); try { await settingsApi.webdavSyncDownload(); toast.success(t("settings.webdavSync.downloadSuccess")); await queryClient.invalidateQueries(); } catch (error) { toast.error( t("settings.webdavSync.downloadFailed", { error: (error as Error)?.message ?? String(error), }), ); } finally { setActionState("idle"); } }, [closeDialog, dirty, queryClient, t]); // ─── Derived state ────────────────────────────────────── const isLoading = actionState !== "idle"; const hasSavedConfig = Boolean( config?.baseUrl?.trim() && config?.username?.trim(), ); const lastSyncAt = config?.status?.lastSyncAt; const lastSyncDisplay = lastSyncAt ? new Date(lastSyncAt * 1000).toLocaleString() : null; const lastError = config?.status?.lastError?.trim(); const showAutoSyncError = !!lastError && (config?.status?.lastErrorSource === "auto" || (!config?.status?.lastErrorSource && !!config?.autoSync)); // ─── Render ───────────────────────────────────────────── return (

{t("settings.webdavSync.title")}

{t("settings.webdavSync.description")}

{/* Config fields */}
{/* Service preset selector */}
{/* Server URL */}
updateField("baseUrl", e.target.value)} onBlur={handleBaseUrlBlur} placeholder={t("settings.webdavSync.baseUrlPlaceholder")} className="text-xs flex-1" disabled={isLoading} />
{/* Username */}
updateField("username", e.target.value)} placeholder={t("settings.webdavSync.usernamePlaceholder")} className="text-xs flex-1" disabled={isLoading} />
{/* Password */}
updateField("password", e.target.value)} placeholder={t("settings.webdavSync.passwordPlaceholder")} className="text-xs flex-1" autoComplete="off" disabled={isLoading} />
{/* Preset hint */} {activePreset?.hint && (
{t(activePreset.hint)}
)} {/* Remote Root */}
updateField("remoteRoot", e.target.value)} placeholder="cc-switch-sync" className="text-xs flex-1" disabled={isLoading} />
{/* Profile */}
updateField("profile", e.target.value)} placeholder="default" className="text-xs flex-1" disabled={isLoading} />
{/* Last sync time */} {lastSyncDisplay && (

{t("settings.webdavSync.lastSync", { time: lastSyncDisplay })}

)} {showAutoSyncError && (

{t("settings.webdavSync.autoSyncLastErrorTitle")}

{lastError}

{t("settings.webdavSync.autoSyncLastErrorHint")}

)} {/* Config buttons + save status */}
{/* Save status indicator */} {dirty && ( {t("settings.webdavSync.unsaved")} )} {!dirty && justSaved && ( {t("settings.webdavSync.saved")} )}
{/* Sync buttons */}
{!hasSavedConfig && (

{t("settings.webdavSync.saveBeforeSync")}

)}
{/* ─── Upload confirmation dialog ──────────────────── */} { if (!open) closeDialog(); }} > {t("settings.webdavSync.confirmUpload.title")}

{t("settings.webdavSync.confirmUpload.content")}

  • {t("settings.webdavSync.confirmUpload.dbItem")}
  • {t("settings.webdavSync.confirmUpload.skillsItem")}

{t("settings.webdavSync.confirmUpload.targetPath")} {": "} /{form.remoteRoot.trim() || "cc-switch-sync"}/v2/ {form.profile.trim() || "default"}

{remoteInfo && (

{t("settings.webdavSync.confirmUpload.existingData")}

{t("settings.webdavSync.confirmUpload.deviceName")}
{remoteInfo.deviceName}
{t("settings.webdavSync.confirmUpload.createdAt")}
{formatDate(remoteInfo.createdAt)}
)} {remoteInfo && (

{t("settings.webdavSync.confirmUpload.warning")}

)}
{/* ─── Download confirmation dialog ────────────────── */} { if (!open) closeDialog(); }} > {t("settings.webdavSync.confirmDownload.title")}
{remoteInfo && (
{t("settings.webdavSync.confirmDownload.deviceName")}
{remoteInfo.deviceName}
{t("settings.webdavSync.confirmDownload.createdAt")}
{formatDate(remoteInfo.createdAt)}
{t("settings.webdavSync.confirmDownload.artifacts")}
{remoteInfo.artifacts.join(", ")}
)}

{t("settings.webdavSync.confirmDownload.warning")}

); }