"use client";
import React from "react";
import Link from "next/link";
import { ActionButton } from "@/components/ui/action-button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "@/components/ui/sonner";
import { Switch } from "@/components/ui/switch";
import { useTranslation } from "@/lib/i18n/client";
import { useUserSettings } from "@/lib/userSettings";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
CheckCircle,
Download,
Play,
Save,
Trash2,
XCircle,
} from "lucide-react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useUpdateUserSettings } from "@karakeep/shared-react/hooks/users";
import { useTRPC } from "@karakeep/shared-react/trpc";
import { zBackupSchema } from "@karakeep/shared/types/backups";
import { zUpdateBackupSettingsSchema } from "@karakeep/shared/types/users";
import { getAssetUrl } from "@karakeep/shared/utils/assetUtils";
import ActionConfirmingDialog from "../ui/action-confirming-dialog";
import { Button } from "../ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { SettingsSection } from "./SettingsPage";
function BackupConfigurationForm() {
const { t } = useTranslation();
const settings = useUserSettings();
const { mutate: updateSettings, isPending: isUpdating } =
useUpdateUserSettings({
onSuccess: () => {
toast({
description: t("settings.info.user_settings.user_settings_updated"),
});
},
onError: () => {
toast({
description: t("common.something_went_wrong"),
variant: "destructive",
});
},
});
const form = useForm<z.infer<typeof zUpdateBackupSettingsSchema>>({
resolver: zodResolver(zUpdateBackupSettingsSchema),
values: settings
? {
backupsEnabled: settings.backupsEnabled,
backupsFrequency: settings.backupsFrequency,
backupsRetentionDays: settings.backupsRetentionDays,
}
: undefined,
});
return (
<SettingsSection title={t("settings.backups.configuration.title")}>
<Form {...form}>
<form
className="space-y-4"
onSubmit={form.handleSubmit((value) => {
updateSettings(value);
})}
>
<FormField
control={form.control}
name="backupsEnabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>
{t(
"settings.backups.configuration.enable_automatic_backups",
)}
</FormLabel>
<FormDescription>
{t(
"settings.backups.configuration.enable_automatic_backups_description",
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="backupsFrequency"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("settings.backups.configuration.backup_frequency")}
</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
{...field}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"settings.backups.configuration.select_frequency",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">
{t("settings.backups.configuration.frequency.daily")}
</SelectItem>
<SelectItem value="weekly">
{t("settings.backups.configuration.frequency.weekly")}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t(
"settings.backups.configuration.backup_frequency_description",
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="backupsRetentionDays"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("settings.backups.configuration.retention_period")}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={365}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value))}
/>
</FormControl>
<FormDescription>
{t(
"settings.backups.configuration.retention_period_description",
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<ActionButton
type="submit"
loading={isUpdating}
className="items-center"
>
<Save className="mr-2 size-4" />
{t("settings.backups.configuration.save_settings")}
</ActionButton>
</form>
</Form>
</SettingsSection>
);
}
function BackupRow({ backup }: { backup: z.infer<typeof zBackupSchema> }) {
const api = useTRPC();
const { t } = useTranslation();
const queryClient = useQueryClient();
const { mutate: deleteBackup, isPending: isDeleting } = useMutation(
api.backups.delete.mutationOptions({
onSuccess: () => {
toast({
description: t("settings.backups.toasts.backup_deleted"),
});
queryClient.invalidateQueries(api.backups.list.pathFilter());
},
onError: (error) => {
toast({
description: `Error: ${error.message}`,
variant: "destructive",
});
},
}),
);
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
return (
<TableRow>
<TableCell>{backup.createdAt.toLocaleString()}</TableCell>
<TableCell>
{backup.status === "pending"
? "-"
: backup.bookmarkCount.toLocaleString()}
</TableCell>
<TableCell>
{backup.status === "pending" ? "-" : formatSize(backup.size)}
</TableCell>
<TableCell>
{backup.status === "success" ? (
<span
title={t("settings.backups.list.status.success")}
className="flex items-center gap-1"
>
<CheckCircle className="size-4 text-green-600" />
{t("settings.backups.list.status.success")}
</span>
) : backup.status === "failure" ? (
<Tooltip>
<TooltipTrigger asChild>
<span
title={
backup.errorMessage ||
t("settings.backups.list.status.failed")
}
className="flex items-center gap-1"
>
<XCircle className="size-4 text-red-600" />
{t("settings.backups.list.status.failed")}
</span>
</TooltipTrigger>
<TooltipContent>{backup.errorMessage}</TooltipContent>
</Tooltip>
) : (
<span className="flex items-center gap-1">
<div className="size-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600" />
{t("settings.backups.list.status.pending")}
</span>
)}
</TableCell>
<TableCell className="flex items-center gap-2">
{backup.assetId && (
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
variant="ghost"
className="items-center"
disabled={backup.status !== "success"}
>
<Link
href={getAssetUrl(backup.assetId)}
download
prefetch={false}
className={
backup.status !== "success"
? "pointer-events-none opacity-50"
: ""
}
>
<Download className="size-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
{t("settings.backups.list.actions.download_backup")}
</TooltipContent>
</Tooltip>
)}
<ActionConfirmingDialog
title={t("settings.backups.dialogs.delete_backup_title")}
description={t("settings.backups.dialogs.delete_backup_description")}
actionButton={() => (
<ActionButton
loading={isDeleting}
variant="destructive"
onClick={() => deleteBackup({ backupId: backup.id })}
className="items-center"
type="button"
>
<Trash2 className="mr-2 size-4" />
{t("settings.backups.list.actions.delete_backup")}
</ActionButton>
)}
>
<Button variant="ghostDestructive" disabled={isDeleting}>
<Trash2 className="size-4" />
</Button>
</ActionConfirmingDialog>
</TableCell>
</TableRow>
);
}
function BackupsList() {
const api = useTRPC();
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: backups, isLoading } = useQuery(
api.backups.list.queryOptions(undefined, {
refetchInterval: (query) => {
const data = query.state.data;
// Poll every 3 seconds if there's a pending backup, otherwise don't poll
return data?.backups.some((backup) => backup.status === "pending")
? 3000
: false;
},
}),
);
const { mutate: triggerBackup, isPending: isTriggering } = useMutation(
api.backups.triggerBackup.mutationOptions({
onSuccess: () => {
toast({
description: t("settings.backups.toasts.backup_queued"),
});
queryClient.invalidateQueries(api.backups.list.pathFilter());
},
onError: (error) => {
toast({
description: `Error: ${error.message}`,
variant: "destructive",
});
},
}),
);
return (
<SettingsSection
title={t("settings.backups.list.title")}
action={
<ActionButton
onClick={() => triggerBackup()}
loading={isTriggering}
variant="default"
className="items-center"
>
<Play className="mr-2 size-4" />
{t("settings.backups.list.create_backup_now")}
</ActionButton>
}
>
{isLoading && <FullPageSpinner />}
{backups && backups.backups.length === 0 && (
<p className="rounded-md bg-muted p-3 text-center text-sm text-muted-foreground">
{t("settings.backups.list.no_backups")}
</p>
)}
{backups && backups.backups.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("settings.backups.list.table.created_at")}
</TableHead>
<TableHead>
{t("settings.backups.list.table.bookmarks")}
</TableHead>
<TableHead>{t("settings.backups.list.table.size")}</TableHead>
<TableHead>{t("settings.backups.list.table.status")}</TableHead>
<TableHead>{t("settings.backups.list.table.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{backups.backups.map((backup) => (
<BackupRow key={backup.id} backup={backup} />
))}
</TableBody>
</Table>
)}
</SettingsSection>
);
}
export default function BackupSettings() {
return (
<>
<BackupConfigurationForm />
<BackupsList />
</>
);
}