aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/settings
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--apps/web/components/settings/AISettings.tsx669
-rw-r--r--apps/web/components/settings/AddApiKey.tsx33
-rw-r--r--apps/web/components/settings/ApiKeySettings.tsx37
-rw-r--r--apps/web/components/settings/BackupSettings.tsx49
-rw-r--r--apps/web/components/settings/ChangePassword.tsx43
-rw-r--r--apps/web/components/settings/DeleteAccount.tsx2
-rw-r--r--apps/web/components/settings/DeleteApiKey.tsx27
-rw-r--r--apps/web/components/settings/FeedSettings.tsx87
-rw-r--r--apps/web/components/settings/ImportExport.tsx38
-rw-r--r--apps/web/components/settings/ImportSessionCard.tsx83
-rw-r--r--apps/web/components/settings/ImportSessionDetail.tsx596
-rw-r--r--apps/web/components/settings/ReaderSettings.tsx311
-rw-r--r--apps/web/components/settings/RegenerateApiKey.tsx35
-rw-r--r--apps/web/components/settings/SubscriptionSettings.tsx34
-rw-r--r--apps/web/components/settings/UserAvatar.tsx149
-rw-r--r--apps/web/components/settings/UserOptions.tsx2
-rw-r--r--apps/web/components/settings/WebhookSettings.tsx58
17 files changed, 1986 insertions, 267 deletions
diff --git a/apps/web/components/settings/AISettings.tsx b/apps/web/components/settings/AISettings.tsx
index beaa93dc..6d28f4f8 100644
--- a/apps/web/components/settings/AISettings.tsx
+++ b/apps/web/components/settings/AISettings.tsx
@@ -1,6 +1,25 @@
"use client";
+import React from "react";
+import { TagsEditor } from "@/components/dashboard/bookmarks/TagsEditor";
import { ActionButton } from "@/components/ui/action-button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Field,
+ FieldContent,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLabel,
+ FieldTitle,
+} from "@/components/ui/field";
import {
Form,
FormControl,
@@ -10,6 +29,7 @@ import {
} from "@/components/ui/form";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
import { Input } from "@/components/ui/input";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
@@ -18,15 +38,22 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { toast } from "@/components/ui/use-toast";
+import { toast } from "@/components/ui/sonner";
+import { Switch } from "@/components/ui/switch";
import { useClientConfig } from "@/lib/clientConfig";
import { useTranslation } from "@/lib/i18n/client";
-import { api } from "@/lib/trpc";
+import { useUserSettings } from "@/lib/userSettings";
+import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
-import { Plus, Save, Trash2 } from "lucide-react";
-import { useForm } from "react-hook-form";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Info, Plus, Save, Trash2 } from "lucide-react";
+import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
+import type { ZBookmarkTags } from "@karakeep/shared/types/tags";
+import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce";
+import { useUpdateUserSettings } from "@karakeep/shared-react/hooks/users";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import {
buildImagePrompt,
buildSummaryPromptUntruncated,
@@ -37,10 +64,426 @@ import {
ZPrompt,
zUpdatePromptSchema,
} from "@karakeep/shared/types/prompts";
+import { zUpdateUserSettingsSchema } from "@karakeep/shared/types/users";
+
+function SettingsSection({
+ title,
+ description,
+ children,
+}: {
+ title?: string;
+ description?: string;
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ <Card>
+ <CardHeader>
+ {title && <CardTitle>{title}</CardTitle>}
+ {description && <CardDescription>{description}</CardDescription>}
+ </CardHeader>
+ <CardContent>{children}</CardContent>
+ </Card>
+ );
+}
+
+export function AIPreferences() {
+ const { t } = useTranslation();
+ const clientConfig = useClientConfig();
+ const settings = useUserSettings();
+
+ const { mutate: updateSettings, isPending } = useUpdateUserSettings({
+ onSuccess: () => {
+ toast({
+ description: "Settings updated successfully!",
+ });
+ },
+ onError: () => {
+ toast({
+ description: "Failed to update settings",
+ variant: "destructive",
+ });
+ },
+ });
+
+ const form = useForm<z.infer<typeof zUpdateUserSettingsSchema>>({
+ resolver: zodResolver(zUpdateUserSettingsSchema),
+ values: settings
+ ? {
+ inferredTagLang: settings.inferredTagLang ?? "",
+ autoTaggingEnabled: settings.autoTaggingEnabled,
+ autoSummarizationEnabled: settings.autoSummarizationEnabled,
+ }
+ : undefined,
+ });
+
+ const showAutoTagging = clientConfig.inference.enableAutoTagging;
+ const showAutoSummarization = clientConfig.inference.enableAutoSummarization;
+
+ const onSubmit = (data: z.infer<typeof zUpdateUserSettingsSchema>) => {
+ updateSettings(data);
+ };
+
+ return (
+ <SettingsSection title="AI preferences">
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <FieldGroup className="gap-3">
+ <Controller
+ name="inferredTagLang"
+ control={form.control}
+ render={({ field, fieldState }) => (
+ <Field
+ className="rounded-lg border p-3"
+ data-invalid={fieldState.invalid}
+ >
+ <FieldContent>
+ <FieldLabel htmlFor="inferredTagLang">
+ {t("settings.ai.inference_language")}
+ </FieldLabel>
+ <FieldDescription>
+ {t("settings.ai.inference_language_description")}
+ </FieldDescription>
+ </FieldContent>
+ <Input
+ {...field}
+ id="inferredTagLang"
+ value={field.value ?? ""}
+ onChange={(e) =>
+ field.onChange(
+ e.target.value.length > 0 ? e.target.value : null,
+ )
+ }
+ aria-invalid={fieldState.invalid}
+ placeholder={`Default (${clientConfig.inference.inferredTagLang})`}
+ type="text"
+ />
+ {fieldState.invalid && (
+ <FieldError errors={[fieldState.error]} />
+ )}
+ </Field>
+ )}
+ />
+
+ {showAutoTagging && (
+ <Controller
+ name="autoTaggingEnabled"
+ control={form.control}
+ render={({ field, fieldState }) => (
+ <Field
+ orientation="horizontal"
+ className="rounded-lg border p-3"
+ data-invalid={fieldState.invalid}
+ >
+ <FieldContent>
+ <FieldLabel htmlFor="autoTaggingEnabled">
+ {t("settings.ai.auto_tagging")}
+ </FieldLabel>
+ <FieldDescription>
+ {t("settings.ai.auto_tagging_description")}
+ </FieldDescription>
+ </FieldContent>
+ <Switch
+ id="autoTaggingEnabled"
+ name={field.name}
+ checked={field.value ?? true}
+ onCheckedChange={field.onChange}
+ aria-invalid={fieldState.invalid}
+ />
+ {fieldState.invalid && (
+ <FieldError errors={[fieldState.error]} />
+ )}
+ </Field>
+ )}
+ />
+ )}
+
+ {showAutoSummarization && (
+ <Controller
+ name="autoSummarizationEnabled"
+ control={form.control}
+ render={({ field, fieldState }) => (
+ <Field
+ orientation="horizontal"
+ className="rounded-lg border p-3"
+ data-invalid={fieldState.invalid}
+ >
+ <FieldContent>
+ <FieldLabel htmlFor="autoSummarizationEnabled">
+ {t("settings.ai.auto_summarization")}
+ </FieldLabel>
+ <FieldDescription>
+ {t("settings.ai.auto_summarization_description")}
+ </FieldDescription>
+ </FieldContent>
+ <Switch
+ id="autoSummarizationEnabled"
+ name={field.name}
+ checked={field.value ?? true}
+ onCheckedChange={field.onChange}
+ aria-invalid={fieldState.invalid}
+ />
+ {fieldState.invalid && (
+ <FieldError errors={[fieldState.error]} />
+ )}
+ </Field>
+ )}
+ />
+ )}
+
+ <div className="flex justify-end pt-4">
+ <ActionButton type="submit" loading={isPending} variant="default">
+ <Save className="mr-2 size-4" />
+ {t("actions.save")}
+ </ActionButton>
+ </div>
+ </FieldGroup>
+ </form>
+ </SettingsSection>
+ );
+}
+
+export function TagStyleSelector() {
+ const { t } = useTranslation();
+ const settings = useUserSettings();
+
+ const { mutate: updateSettings, isPending: isUpdating } =
+ useUpdateUserSettings({
+ onSuccess: () => {
+ toast({
+ description: "Tag style updated successfully!",
+ });
+ },
+ onError: () => {
+ toast({
+ description: "Failed to update tag style",
+ variant: "destructive",
+ });
+ },
+ });
+
+ const tagStyleOptions = [
+ {
+ value: "lowercase-hyphens",
+ label: t("settings.ai.lowercase_hyphens"),
+ examples: ["machine-learning", "web-development"],
+ },
+ {
+ value: "lowercase-spaces",
+ label: t("settings.ai.lowercase_spaces"),
+ examples: ["machine learning", "web development"],
+ },
+ {
+ value: "lowercase-underscores",
+ label: t("settings.ai.lowercase_underscores"),
+ examples: ["machine_learning", "web_development"],
+ },
+ {
+ value: "titlecase-spaces",
+ label: t("settings.ai.titlecase_spaces"),
+ examples: ["Machine Learning", "Web Development"],
+ },
+ {
+ value: "titlecase-hyphens",
+ label: t("settings.ai.titlecase_hyphens"),
+ examples: ["Machine-Learning", "Web-Development"],
+ },
+ {
+ value: "camelCase",
+ label: t("settings.ai.camelCase"),
+ examples: ["machineLearning", "webDevelopment"],
+ },
+ {
+ value: "as-generated",
+ label: t("settings.ai.no_preference"),
+ examples: ["Machine Learning", "web development", "AI_generated"],
+ },
+ ] as const;
+
+ const selectedStyle = settings?.tagStyle ?? "as-generated";
+
+ return (
+ <SettingsSection
+ title={t("settings.ai.tag_style")}
+ description={t("settings.ai.tag_style_description")}
+ >
+ <RadioGroup
+ value={selectedStyle}
+ onValueChange={(value) => {
+ updateSettings({ tagStyle: value as typeof selectedStyle });
+ }}
+ disabled={isUpdating}
+ className="grid gap-3 sm:grid-cols-2"
+ >
+ {tagStyleOptions.map((option) => (
+ <FieldLabel
+ key={option.value}
+ htmlFor={option.value}
+ className={cn(selectedStyle === option.value && "ring-1")}
+ >
+ <Field orientation="horizontal">
+ <FieldContent>
+ <FieldTitle>{option.label}</FieldTitle>
+ <div className="flex flex-wrap gap-1">
+ {option.examples.map((example) => (
+ <Badge
+ key={example}
+ variant="secondary"
+ className="text-xs font-light"
+ >
+ {example}
+ </Badge>
+ ))}
+ </div>
+ </FieldContent>
+ <RadioGroupItem value={option.value} id={option.value} />
+ </Field>
+ </FieldLabel>
+ ))}
+ </RadioGroup>
+ </SettingsSection>
+ );
+}
+
+export function CuratedTagsSelector() {
+ const api = useTRPC();
+ const { t } = useTranslation();
+ const settings = useUserSettings();
+
+ const { mutate: updateSettings, isPending: isUpdatingCuratedTags } =
+ useUpdateUserSettings({
+ onSuccess: () => {
+ toast({
+ description: t("settings.ai.curated_tags_updated"),
+ });
+ },
+ onError: () => {
+ toast({
+ description: t("settings.ai.curated_tags_update_failed"),
+ variant: "destructive",
+ });
+ },
+ });
+
+ const areTagIdsEqual = React.useCallback((a: string[], b: string[]) => {
+ return a.length === b.length && a.every((id, index) => id === b[index]);
+ }, []);
+
+ const curatedTagIds = React.useMemo(
+ () => settings?.curatedTagIds ?? [],
+ [settings?.curatedTagIds],
+ );
+ const [localCuratedTagIds, setLocalCuratedTagIds] =
+ React.useState<string[]>(curatedTagIds);
+ const debouncedCuratedTagIds = useDebounce(localCuratedTagIds, 300);
+ const lastServerCuratedTagIdsRef = React.useRef(curatedTagIds);
+ const lastSubmittedCuratedTagIdsRef = React.useRef<string[] | null>(null);
+
+ React.useEffect(() => {
+ const hadUnsyncedLocalChanges = !areTagIdsEqual(
+ localCuratedTagIds,
+ lastServerCuratedTagIdsRef.current,
+ );
+
+ if (
+ !hadUnsyncedLocalChanges &&
+ !areTagIdsEqual(localCuratedTagIds, curatedTagIds)
+ ) {
+ setLocalCuratedTagIds(curatedTagIds);
+ }
+
+ lastServerCuratedTagIdsRef.current = curatedTagIds;
+ }, [areTagIdsEqual, curatedTagIds, localCuratedTagIds]);
+
+ React.useEffect(() => {
+ if (isUpdatingCuratedTags) {
+ return;
+ }
+
+ if (areTagIdsEqual(debouncedCuratedTagIds, curatedTagIds)) {
+ lastSubmittedCuratedTagIdsRef.current = null;
+ return;
+ }
+
+ if (
+ lastSubmittedCuratedTagIdsRef.current &&
+ areTagIdsEqual(
+ lastSubmittedCuratedTagIdsRef.current,
+ debouncedCuratedTagIds,
+ )
+ ) {
+ return;
+ }
+
+ lastSubmittedCuratedTagIdsRef.current = debouncedCuratedTagIds;
+ updateSettings({
+ curatedTagIds:
+ debouncedCuratedTagIds.length > 0 ? debouncedCuratedTagIds : null,
+ });
+ }, [
+ areTagIdsEqual,
+ curatedTagIds,
+ debouncedCuratedTagIds,
+ isUpdatingCuratedTags,
+ updateSettings,
+ ]);
+
+ // Fetch selected tags to display their names
+ const { data: selectedTagsData } = useQuery(
+ api.tags.list.queryOptions(
+ { ids: localCuratedTagIds },
+ { enabled: localCuratedTagIds.length > 0 },
+ ),
+ );
+
+ const selectedTags: ZBookmarkTags[] = React.useMemo(() => {
+ const tagsMap = new Map(
+ (selectedTagsData?.tags ?? []).map((tag) => [tag.id, tag]),
+ );
+ // Preserve the order from curatedTagIds instead of server sort order
+ return localCuratedTagIds
+ .map((id) => tagsMap.get(id))
+ .filter((tag): tag is NonNullable<typeof tag> => tag != null)
+ .map((tag) => ({
+ id: tag.id,
+ name: tag.name,
+ attachedBy: "human" as const,
+ }));
+ }, [selectedTagsData?.tags, localCuratedTagIds]);
+
+ return (
+ <SettingsSection
+ title={t("settings.ai.curated_tags")}
+ description={t("settings.ai.curated_tags_description")}
+ >
+ <TagsEditor
+ tags={selectedTags}
+ placeholder="Select curated tags..."
+ onAttach={(tag) => {
+ const tagId = tag.tagId;
+ if (tagId) {
+ setLocalCuratedTagIds((prev) => {
+ if (prev.includes(tagId)) {
+ return prev;
+ }
+ return [...prev, tagId];
+ });
+ }
+ }}
+ onDetach={(tag) => {
+ setLocalCuratedTagIds((prev) => {
+ return prev.filter((id) => id !== tag.tagId);
+ });
+ }}
+ allowCreation={false}
+ />
+ </SettingsSection>
+ );
+}
export function PromptEditor() {
+ const api = useTRPC();
const { t } = useTranslation();
- const apiUtils = api.useUtils();
+ const queryClient = useQueryClient();
const form = useForm<z.infer<typeof zNewPromptSchema>>({
resolver: zodResolver(zNewPromptSchema),
@@ -50,15 +493,16 @@ export function PromptEditor() {
},
});
- const { mutateAsync: createPrompt, isPending: isCreating } =
- api.prompts.create.useMutation({
+ const { mutateAsync: createPrompt, isPending: isCreating } = useMutation(
+ api.prompts.create.mutationOptions({
onSuccess: () => {
toast({
description: "Prompt has been created!",
});
- apiUtils.prompts.list.invalidate();
+ queryClient.invalidateQueries(api.prompts.list.pathFilter());
},
- });
+ }),
+ );
return (
<Form {...form}>
@@ -140,26 +584,29 @@ export function PromptEditor() {
}
export function PromptRow({ prompt }: { prompt: ZPrompt }) {
+ const api = useTRPC();
const { t } = useTranslation();
- const apiUtils = api.useUtils();
- const { mutateAsync: updatePrompt, isPending: isUpdating } =
- api.prompts.update.useMutation({
+ const queryClient = useQueryClient();
+ const { mutateAsync: updatePrompt, isPending: isUpdating } = useMutation(
+ api.prompts.update.mutationOptions({
onSuccess: () => {
toast({
description: "Prompt has been updated!",
});
- apiUtils.prompts.list.invalidate();
+ queryClient.invalidateQueries(api.prompts.list.pathFilter());
},
- });
- const { mutate: deletePrompt, isPending: isDeleting } =
- api.prompts.delete.useMutation({
+ }),
+ );
+ const { mutate: deletePrompt, isPending: isDeleting } = useMutation(
+ api.prompts.delete.mutationOptions({
onSuccess: () => {
toast({
description: "Prompt has been deleted!",
});
- apiUtils.prompts.list.invalidate();
+ queryClient.invalidateQueries(api.prompts.list.pathFilter());
},
- });
+ }),
+ );
const form = useForm<z.infer<typeof zUpdatePromptSchema>>({
resolver: zodResolver(zUpdatePromptSchema),
@@ -273,92 +720,144 @@ export function PromptRow({ prompt }: { prompt: ZPrompt }) {
}
export function TaggingRules() {
+ const api = useTRPC();
const { t } = useTranslation();
- const { data: prompts, isLoading } = api.prompts.list.useQuery();
+ const { data: prompts, isLoading } = useQuery(
+ api.prompts.list.queryOptions(),
+ );
return (
- <div className="mt-2 flex flex-col gap-2">
- <div className="w-full text-xl font-medium sm:w-1/3">
- {t("settings.ai.tagging_rules")}
- </div>
- <p className="mb-1 text-xs italic text-muted-foreground">
- {t("settings.ai.tagging_rule_description")}
- </p>
- {isLoading && <FullPageSpinner />}
+ <SettingsSection
+ title={t("settings.ai.tagging_rules")}
+ description={t("settings.ai.tagging_rule_description")}
+ >
{prompts && prompts.length == 0 && (
- <p className="rounded-md bg-muted p-2 text-sm text-muted-foreground">
- You don&apos;t have any custom prompts yet.
- </p>
+ <div className="flex items-start gap-2 rounded-md bg-muted p-4 text-sm text-muted-foreground">
+ <Info className="size-4 flex-shrink-0" />
+ <p>You don&apos;t have any custom prompts yet.</p>
+ </div>
)}
- {prompts &&
- prompts.map((prompt) => <PromptRow key={prompt.id} prompt={prompt} />)}
- <PromptEditor />
- </div>
+ <div className="flex flex-col gap-2">
+ {isLoading && <FullPageSpinner />}
+ {prompts &&
+ prompts.map((prompt) => (
+ <PromptRow key={prompt.id} prompt={prompt} />
+ ))}
+ <PromptEditor />
+ </div>
+ </SettingsSection>
);
}
export function PromptDemo() {
+ const api = useTRPC();
const { t } = useTranslation();
- const { data: prompts } = api.prompts.list.useQuery();
+ const { data: prompts } = useQuery(api.prompts.list.queryOptions());
+ const settings = useUserSettings();
const clientConfig = useClientConfig();
+ const tagStyle = settings?.tagStyle ?? "as-generated";
+ const curatedTagIds = settings?.curatedTagIds ?? [];
+ const { data: tagsData } = useQuery(
+ api.tags.list.queryOptions(
+ { ids: curatedTagIds },
+ { enabled: curatedTagIds.length > 0 },
+ ),
+ );
+ const inferredTagLang =
+ settings?.inferredTagLang ?? clientConfig.inference.inferredTagLang;
+
+ // Resolve curated tag names for preview
+ const curatedTagNames =
+ curatedTagIds.length > 0 && tagsData?.tags
+ ? curatedTagIds
+ .map((id) => tagsData.tags.find((tag) => tag.id === id)?.name)
+ .filter((name): name is string => Boolean(name))
+ : undefined;
+
return (
- <div className="flex flex-col gap-2">
- <div className="mb-4 w-full text-xl font-medium sm:w-1/3">
- {t("settings.ai.prompt_preview")}
+ <SettingsSection
+ title={t("settings.ai.prompt_preview")}
+ description="Preview the actual prompts sent to AI based on your settings"
+ >
+ <div className="space-y-4">
+ <div>
+ <p className="mb-2 text-sm font-medium">
+ {t("settings.ai.text_prompt")}
+ </p>
+ <code className="block whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground">
+ {buildTextPromptUntruncated(
+ inferredTagLang,
+ (prompts ?? [])
+ .filter(
+ (p) => p.appliesTo == "text" || p.appliesTo == "all_tagging",
+ )
+ .map((p) => p.text),
+ "\n<CONTENT_HERE>\n",
+ tagStyle,
+ curatedTagNames,
+ ).trim()}
+ </code>
+ </div>
+ <div>
+ <p className="mb-2 text-sm font-medium">
+ {t("settings.ai.images_prompt")}
+ </p>
+ <code className="block whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground">
+ {buildImagePrompt(
+ inferredTagLang,
+ (prompts ?? [])
+ .filter(
+ (p) =>
+ p.appliesTo == "images" || p.appliesTo == "all_tagging",
+ )
+ .map((p) => p.text),
+ tagStyle,
+ curatedTagNames,
+ ).trim()}
+ </code>
+ </div>
+ <div>
+ <p className="mb-2 text-sm font-medium">
+ {t("settings.ai.summarization_prompt")}
+ </p>
+ <code className="block whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground">
+ {buildSummaryPromptUntruncated(
+ inferredTagLang,
+ (prompts ?? [])
+ .filter((p) => p.appliesTo == "summary")
+ .map((p) => p.text),
+ "\n<CONTENT_HERE>\n",
+ ).trim()}
+ </code>
+ </div>
</div>
- <p>{t("settings.ai.text_prompt")}</p>
- <code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground">
- {buildTextPromptUntruncated(
- clientConfig.inference.inferredTagLang,
- (prompts ?? [])
- .filter(
- (p) => p.appliesTo == "text" || p.appliesTo == "all_tagging",
- )
- .map((p) => p.text),
- "\n<CONTENT_HERE>\n",
- ).trim()}
- </code>
- <p>{t("settings.ai.images_prompt")}</p>
- <code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground">
- {buildImagePrompt(
- clientConfig.inference.inferredTagLang,
- (prompts ?? [])
- .filter(
- (p) => p.appliesTo == "images" || p.appliesTo == "all_tagging",
- )
- .map((p) => p.text),
- ).trim()}
- </code>
- <p>{t("settings.ai.summarization_prompt")}</p>
- <code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground">
- {buildSummaryPromptUntruncated(
- clientConfig.inference.inferredTagLang,
- (prompts ?? [])
- .filter((p) => p.appliesTo == "summary")
- .map((p) => p.text),
- "\n<CONTENT_HERE>\n",
- ).trim()}
- </code>
- </div>
+ </SettingsSection>
);
}
export default function AISettings() {
const { t } = useTranslation();
return (
- <>
- <div className="rounded-md border bg-background p-4">
- <div className="mb-2 flex flex-col gap-3">
- <div className="w-full text-2xl font-medium sm:w-1/3">
- {t("settings.ai.ai_settings")}
- </div>
- <TaggingRules />
- </div>
- </div>
- <div className="mt-4 rounded-md border bg-background p-4">
- <PromptDemo />
- </div>
- </>
+ <div className="space-y-6">
+ <h2 className="text-3xl font-bold tracking-tight">
+ {t("settings.ai.ai_settings")}
+ </h2>
+
+ {/* AI Preferences */}
+ <AIPreferences />
+
+ {/* Tag Style */}
+ <TagStyleSelector />
+
+ {/* Curated Tags */}
+ <CuratedTagsSelector />
+
+ {/* Tagging Rules */}
+ <TaggingRules />
+
+ {/* Prompt Preview */}
+ <PromptDemo />
+ </div>
);
}
diff --git a/apps/web/components/settings/AddApiKey.tsx b/apps/web/components/settings/AddApiKey.tsx
index c8baa626..b6612a51 100644
--- a/apps/web/components/settings/AddApiKey.tsx
+++ b/apps/web/components/settings/AddApiKey.tsx
@@ -24,34 +24,39 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
-import { toast } from "@/components/ui/use-toast";
+import { toast } from "@/components/ui/sonner";
import { useTranslation } from "@/lib/i18n/client";
-import { api } from "@/lib/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
+import { useMutation } from "@tanstack/react-query";
import { PlusCircle } from "lucide-react";
import { useForm } from "react-hook-form";
import { z } from "zod";
+import { useTRPC } from "@karakeep/shared-react/trpc";
+
import ApiKeySuccess from "./ApiKeySuccess";
function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) {
+ const api = useTRPC();
const { t } = useTranslation();
const formSchema = z.object({
name: z.string(),
});
const router = useRouter();
- const mutator = api.apiKeys.create.useMutation({
- onSuccess: (resp) => {
- onSuccess(resp.key);
- router.refresh();
- },
- onError: () => {
- toast({
- description: t("common.something_went_wrong"),
- variant: "destructive",
- });
- },
- });
+ const mutator = useMutation(
+ api.apiKeys.create.mutationOptions({
+ onSuccess: (resp) => {
+ onSuccess(resp.key);
+ router.refresh();
+ },
+ onError: () => {
+ toast({
+ description: t("common.something_went_wrong"),
+ variant: "destructive",
+ });
+ },
+ }),
+ );
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
diff --git a/apps/web/components/settings/ApiKeySettings.tsx b/apps/web/components/settings/ApiKeySettings.tsx
index bc4b71c5..fa8b4927 100644
--- a/apps/web/components/settings/ApiKeySettings.tsx
+++ b/apps/web/components/settings/ApiKeySettings.tsx
@@ -8,6 +8,7 @@ import {
} from "@/components/ui/table";
import { useTranslation } from "@/lib/i18n/server";
import { api } from "@/server/api/client";
+import { formatDistanceToNow } from "date-fns";
import AddApiKey from "./AddApiKey";
import DeleteApiKey from "./DeleteApiKey";
@@ -32,23 +33,33 @@ export default async function ApiKeys() {
<TableHead>{t("common.name")}</TableHead>
<TableHead>{t("common.key")}</TableHead>
<TableHead>{t("common.created_at")}</TableHead>
+ <TableHead>{t("common.last_used")}</TableHead>
<TableHead>{t("common.action")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
- {keys.keys.map((k) => (
- <TableRow key={k.id}>
- <TableCell>{k.name}</TableCell>
- <TableCell>**_{k.keyId}_**</TableCell>
- <TableCell>{k.createdAt.toLocaleString()}</TableCell>
- <TableCell>
- <div className="flex items-center gap-2">
- <RegenerateApiKey name={k.name} id={k.id} />
- <DeleteApiKey name={k.name} id={k.id} />
- </div>
- </TableCell>
- </TableRow>
- ))}
+ {keys.keys.map((key) => {
+ return (
+ <TableRow key={key.id}>
+ <TableCell>{key.name}</TableCell>
+ <TableCell>**_{key.keyId}_**</TableCell>
+ <TableCell>
+ {formatDistanceToNow(key.createdAt, { addSuffix: true })}
+ </TableCell>
+ <TableCell>
+ {key.lastUsedAt
+ ? formatDistanceToNow(key.lastUsedAt, { addSuffix: true })
+ : "—"}
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-2">
+ <RegenerateApiKey name={key.name} id={key.id} />
+ <DeleteApiKey name={key.name} id={key.id} />
+ </div>
+ </TableCell>
+ </TableRow>
+ );
+ })}
<TableRow></TableRow>
</TableBody>
</Table>
diff --git a/apps/web/components/settings/BackupSettings.tsx b/apps/web/components/settings/BackupSettings.tsx
index 18a80993..57672fb0 100644
--- a/apps/web/components/settings/BackupSettings.tsx
+++ b/apps/web/components/settings/BackupSettings.tsx
@@ -21,12 +21,12 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { toast } from "@/components/ui/sonner";
import { Switch } from "@/components/ui/switch";
-import { toast } from "@/components/ui/use-toast";
import { useTranslation } from "@/lib/i18n/client";
-import { api } from "@/lib/trpc";
import { useUserSettings } from "@/lib/userSettings";
import { zodResolver } from "@hookform/resolvers/zod";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
CheckCircle,
Download,
@@ -39,6 +39,7 @@ 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";
@@ -207,16 +208,17 @@ function BackupConfigurationForm() {
}
function BackupRow({ backup }: { backup: z.infer<typeof zBackupSchema> }) {
+ const api = useTRPC();
const { t } = useTranslation();
- const apiUtils = api.useUtils();
+ const queryClient = useQueryClient();
- const { mutate: deleteBackup, isPending: isDeleting } =
- api.backups.delete.useMutation({
+ const { mutate: deleteBackup, isPending: isDeleting } = useMutation(
+ api.backups.delete.mutationOptions({
onSuccess: () => {
toast({
description: t("settings.backups.toasts.backup_deleted"),
});
- apiUtils.backups.list.invalidate();
+ queryClient.invalidateQueries(api.backups.list.pathFilter());
},
onError: (error) => {
toast({
@@ -224,7 +226,8 @@ function BackupRow({ backup }: { backup: z.infer<typeof zBackupSchema> }) {
variant: "destructive",
});
},
- });
+ }),
+ );
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
@@ -330,25 +333,28 @@ function BackupRow({ backup }: { backup: z.infer<typeof zBackupSchema> }) {
}
function BackupsList() {
+ const api = useTRPC();
const { t } = useTranslation();
- const apiUtils = api.useUtils();
- const { data: backups, isLoading } = api.backups.list.useQuery(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 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 } =
- api.backups.triggerBackup.useMutation({
+ const { mutate: triggerBackup, isPending: isTriggering } = useMutation(
+ api.backups.triggerBackup.mutationOptions({
onSuccess: () => {
toast({
description: t("settings.backups.toasts.backup_queued"),
});
- apiUtils.backups.list.invalidate();
+ queryClient.invalidateQueries(api.backups.list.pathFilter());
},
onError: (error) => {
toast({
@@ -356,7 +362,8 @@ function BackupsList() {
variant: "destructive",
});
},
- });
+ }),
+ );
return (
<div className="rounded-md border bg-background p-4">
diff --git a/apps/web/components/settings/ChangePassword.tsx b/apps/web/components/settings/ChangePassword.tsx
index a27741d9..481d4b95 100644
--- a/apps/web/components/settings/ChangePassword.tsx
+++ b/apps/web/components/settings/ChangePassword.tsx
@@ -12,19 +12,21 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
-import { toast } from "@/components/ui/use-toast";
+import { toast } from "@/components/ui/sonner";
import { useTranslation } from "@/lib/i18n/client";
-import { api } from "@/lib/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
+import { useMutation } from "@tanstack/react-query";
import { Eye, EyeOff, Lock } from "lucide-react";
import { useForm } from "react-hook-form";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import { zChangePasswordSchema } from "@karakeep/shared/types/users";
import { Button } from "../ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
export function ChangePassword() {
+ const api = useTRPC();
const { t } = useTranslation();
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
@@ -38,22 +40,27 @@ export function ChangePassword() {
},
});
- const mutator = api.users.changePassword.useMutation({
- onSuccess: () => {
- toast({ description: "Password changed successfully" });
- form.reset();
- },
- onError: (e) => {
- if (e.data?.code == "UNAUTHORIZED") {
- toast({
- description: "Your current password is incorrect",
- variant: "destructive",
- });
- } else {
- toast({ description: "Something went wrong", variant: "destructive" });
- }
- },
- });
+ const mutator = useMutation(
+ api.users.changePassword.mutationOptions({
+ onSuccess: () => {
+ toast({ description: "Password changed successfully" });
+ form.reset();
+ },
+ onError: (e) => {
+ if (e.data?.code == "UNAUTHORIZED") {
+ toast({
+ description: "Your current password is incorrect",
+ variant: "destructive",
+ });
+ } else {
+ toast({
+ description: "Something went wrong",
+ variant: "destructive",
+ });
+ }
+ },
+ }),
+ );
async function onSubmit(value: z.infer<typeof zChangePasswordSchema>) {
mutator.mutate({
diff --git a/apps/web/components/settings/DeleteAccount.tsx b/apps/web/components/settings/DeleteAccount.tsx
index 6ebafff9..5ccbfaf7 100644
--- a/apps/web/components/settings/DeleteAccount.tsx
+++ b/apps/web/components/settings/DeleteAccount.tsx
@@ -13,7 +13,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
-import { toast } from "@/components/ui/use-toast";
+import { toast } from "@/components/ui/sonner";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Eye, EyeOff, Trash2 } from "lucide-react";
import { useForm } from "react-hook-form";
diff --git a/apps/web/components/settings/DeleteApiKey.tsx b/apps/web/components/settings/DeleteApiKey.tsx
index 4efb7ea8..b4cf7eea 100644
--- a/apps/web/components/settings/DeleteApiKey.tsx
+++ b/apps/web/components/settings/DeleteApiKey.tsx
@@ -4,10 +4,12 @@ import { useRouter } from "next/navigation";
import { ActionButton } from "@/components/ui/action-button";
import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog";
import { Button } from "@/components/ui/button";
-import { toast } from "@/components/ui/use-toast";
import { useTranslation } from "@/lib/i18n/client";
-import { api } from "@/lib/trpc";
+import { useMutation } from "@tanstack/react-query";
import { Trash } from "lucide-react";
+import { toast } from "sonner";
+
+import { useTRPC } from "@karakeep/shared-react/trpc";
export default function DeleteApiKey({
name,
@@ -16,16 +18,17 @@ export default function DeleteApiKey({
name: string;
id: string;
}) {
+ const api = useTRPC();
const { t } = useTranslation();
const router = useRouter();
- const mutator = api.apiKeys.revoke.useMutation({
- onSuccess: () => {
- toast({
- description: "Key was successfully deleted",
- });
- router.refresh();
- },
- });
+ const mutator = useMutation(
+ api.apiKeys.revoke.mutationOptions({
+ onSuccess: () => {
+ toast.success("Key was successfully deleted");
+ router.refresh();
+ },
+ }),
+ );
return (
<ActionConfirmingDialog
@@ -49,8 +52,8 @@ export default function DeleteApiKey({
</ActionButton>
)}
>
- <Button variant="outline">
- <Trash size={18} color="red" />
+ <Button variant="ghost" title={t("actions.delete")}>
+ <Trash size={18} />
</Button>
</ActionConfirmingDialog>
);
diff --git a/apps/web/components/settings/FeedSettings.tsx b/apps/web/components/settings/FeedSettings.tsx
index 23b639e4..ba1568a7 100644
--- a/apps/web/components/settings/FeedSettings.tsx
+++ b/apps/web/components/settings/FeedSettings.tsx
@@ -13,12 +13,12 @@ import {
} from "@/components/ui/form";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
import { Input } from "@/components/ui/input";
+import { toast } from "@/components/ui/sonner";
import { Switch } from "@/components/ui/switch";
-import { toast } from "@/components/ui/use-toast";
import { useTranslation } from "@/lib/i18n/client";
-import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowDownToLine,
CheckCircle,
@@ -33,6 +33,7 @@ import {
import { useForm } from "react-hook-form";
import { z } from "zod";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import {
ZFeed,
zNewFeedSchema,
@@ -61,9 +62,10 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
export function FeedsEditorDialog() {
+ const api = useTRPC();
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
- const apiUtils = api.useUtils();
+ const queryClient = useQueryClient();
const form = useForm<z.infer<typeof zNewFeedSchema>>({
resolver: zodResolver(zNewFeedSchema),
@@ -81,16 +83,17 @@ export function FeedsEditorDialog() {
}
}, [open]);
- const { mutateAsync: createFeed, isPending: isCreating } =
- api.feeds.create.useMutation({
+ const { mutateAsync: createFeed, isPending: isCreating } = useMutation(
+ api.feeds.create.mutationOptions({
onSuccess: () => {
toast({
description: "Feed has been created!",
});
- apiUtils.feeds.list.invalidate();
+ queryClient.invalidateQueries(api.feeds.list.pathFilter());
setOpen(false);
},
- });
+ }),
+ );
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -191,8 +194,9 @@ export function FeedsEditorDialog() {
}
export function EditFeedDialog({ feed }: { feed: ZFeed }) {
+ const api = useTRPC();
const { t } = useTranslation();
- const apiUtils = api.useUtils();
+ const queryClient = useQueryClient();
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
if (open) {
@@ -204,16 +208,17 @@ export function EditFeedDialog({ feed }: { feed: ZFeed }) {
});
}
}, [open]);
- const { mutateAsync: updateFeed, isPending: isUpdating } =
- api.feeds.update.useMutation({
+ const { mutateAsync: updateFeed, isPending: isUpdating } = useMutation(
+ api.feeds.update.mutationOptions({
onSuccess: () => {
toast({
description: "Feed has been updated!",
});
setOpen(false);
- apiUtils.feeds.list.invalidate();
+ queryClient.invalidateQueries(api.feeds.list.pathFilter());
},
- });
+ }),
+ );
const form = useForm<z.infer<typeof zUpdateFeedSchema>>({
resolver: zodResolver(zUpdateFeedSchema),
defaultValues: {
@@ -339,44 +344,49 @@ export function EditFeedDialog({ feed }: { feed: ZFeed }) {
}
export function FeedRow({ feed }: { feed: ZFeed }) {
+ const api = useTRPC();
const { t } = useTranslation();
- const apiUtils = api.useUtils();
- const { mutate: deleteFeed, isPending: isDeleting } =
- api.feeds.delete.useMutation({
+ const queryClient = useQueryClient();
+ const { mutate: deleteFeed, isPending: isDeleting } = useMutation(
+ api.feeds.delete.mutationOptions({
onSuccess: () => {
toast({
description: "Feed has been deleted!",
});
- apiUtils.feeds.list.invalidate();
+ queryClient.invalidateQueries(api.feeds.list.pathFilter());
},
- });
+ }),
+ );
- const { mutate: fetchNow, isPending: isFetching } =
- api.feeds.fetchNow.useMutation({
+ const { mutate: fetchNow, isPending: isFetching } = useMutation(
+ api.feeds.fetchNow.mutationOptions({
onSuccess: () => {
toast({
description: "Feed fetch has been enqueued!",
});
- apiUtils.feeds.list.invalidate();
+ queryClient.invalidateQueries(api.feeds.list.pathFilter());
},
- });
+ }),
+ );
- const { mutate: updateFeedEnabled } = api.feeds.update.useMutation({
- onSuccess: () => {
- toast({
- description: feed.enabled
- ? t("settings.feeds.feed_disabled")
- : t("settings.feeds.feed_enabled"),
- });
- apiUtils.feeds.list.invalidate();
- },
- onError: (error) => {
- toast({
- description: `Error: ${error.message}`,
- variant: "destructive",
- });
- },
- });
+ const { mutate: updateFeedEnabled } = useMutation(
+ api.feeds.update.mutationOptions({
+ onSuccess: () => {
+ toast({
+ description: feed.enabled
+ ? t("settings.feeds.feed_disabled")
+ : t("settings.feeds.feed_enabled"),
+ });
+ queryClient.invalidateQueries(api.feeds.list.pathFilter());
+ },
+ onError: (error) => {
+ toast({
+ description: `Error: ${error.message}`,
+ variant: "destructive",
+ });
+ },
+ }),
+ );
const handleToggle = (checked: boolean) => {
updateFeedEnabled({ feedId: feed.id, enabled: checked });
@@ -456,8 +466,9 @@ export function FeedRow({ feed }: { feed: ZFeed }) {
}
export default function FeedSettings() {
+ const api = useTRPC();
const { t } = useTranslation();
- const { data: feeds, isLoading } = api.feeds.list.useQuery();
+ const { data: feeds, isLoading } = useQuery(api.feeds.list.queryOptions());
return (
<>
<div className="rounded-md border bg-background p-4">
diff --git a/apps/web/components/settings/ImportExport.tsx b/apps/web/components/settings/ImportExport.tsx
index b6e4da9a..e02297c9 100644
--- a/apps/web/components/settings/ImportExport.tsx
+++ b/apps/web/components/settings/ImportExport.tsx
@@ -12,6 +12,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { toast } from "@/components/ui/sonner";
import { useBookmarkImport } from "@/lib/hooks/useBookmarkImport";
import { useTranslation } from "@/lib/i18n/client";
import { cn } from "@/lib/utils";
@@ -19,7 +20,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, Download, Loader2, Upload } from "lucide-react";
import { Card, CardContent } from "../ui/card";
-import { toast } from "../ui/use-toast";
import { ImportSessionsSection } from "./ImportSessionsSection";
function ImportCard({
@@ -180,6 +180,23 @@ export function ImportExportRow() {
</FilePickerButton>
</ImportCard>
<ImportCard
+ text="Matter"
+ description={t("settings.import.import_bookmarks_from_matter_export")}
+ >
+ <FilePickerButton
+ size={"sm"}
+ loading={false}
+ accept=".csv"
+ multiple={false}
+ className="flex items-center gap-2"
+ onFileSelect={(file) =>
+ runUploadBookmarkFile({ file, source: "matter" })
+ }
+ >
+ <p>Import</p>
+ </FilePickerButton>
+ </ImportCard>
+ <ImportCard
text="Omnivore"
description={t(
"settings.import.import_bookmarks_from_omnivore_export",
@@ -254,6 +271,25 @@ export function ImportExportRow() {
</FilePickerButton>
</ImportCard>
<ImportCard
+ text="Instapaper"
+ description={t(
+ "settings.import.import_bookmarks_from_instapaper_export",
+ )}
+ >
+ <FilePickerButton
+ size={"sm"}
+ loading={false}
+ accept=".csv"
+ multiple={false}
+ className="flex items-center gap-2"
+ onFileSelect={(file) =>
+ runUploadBookmarkFile({ file, source: "instapaper" })
+ }
+ >
+ <p>Import</p>
+ </FilePickerButton>
+ </ImportCard>
+ <ImportCard
text="Karakeep"
description={t(
"settings.import.import_bookmarks_from_karakeep_export",
diff --git a/apps/web/components/settings/ImportSessionCard.tsx b/apps/web/components/settings/ImportSessionCard.tsx
index 690caaa5..f62a00dd 100644
--- a/apps/web/components/settings/ImportSessionCard.tsx
+++ b/apps/web/components/settings/ImportSessionCard.tsx
@@ -9,6 +9,8 @@ import { Progress } from "@/components/ui/progress";
import {
useDeleteImportSession,
useImportSessionStats,
+ usePauseImportSession,
+ useResumeImportSession,
} from "@/lib/hooks/useImportSessions";
import { useTranslation } from "@/lib/i18n/client";
import { formatDistanceToNow } from "date-fns";
@@ -19,10 +21,17 @@ import {
Clock,
ExternalLink,
Loader2,
+ Pause,
+ Play,
Trash2,
+ Upload,
} from "lucide-react";
-import type { ZImportSessionWithStats } from "@karakeep/shared/types/importSessions";
+import type {
+ ZImportSessionStatus,
+ ZImportSessionWithStats,
+} from "@karakeep/shared/types/importSessions";
+import { switchCase } from "@karakeep/shared/utils/switch";
interface ImportSessionCardProps {
session: ZImportSessionWithStats;
@@ -30,10 +39,14 @@ interface ImportSessionCardProps {
function getStatusColor(status: string) {
switch (status) {
+ case "staging":
+ return "bg-purple-500/10 text-purple-700 dark:text-purple-400";
case "pending":
return "bg-muted text-muted-foreground";
- case "in_progress":
+ case "running":
return "bg-blue-500/10 text-blue-700 dark:text-blue-400";
+ case "paused":
+ return "bg-yellow-500/10 text-yellow-700 dark:text-yellow-400";
case "completed":
return "bg-green-500/10 text-green-700 dark:text-green-400";
case "failed":
@@ -45,10 +58,14 @@ function getStatusColor(status: string) {
function getStatusIcon(status: string) {
switch (status) {
+ case "staging":
+ return <Upload className="h-4 w-4" />;
case "pending":
return <Clock className="h-4 w-4" />;
- case "in_progress":
+ case "running":
return <Loader2 className="h-4 w-4 animate-spin" />;
+ case "paused":
+ return <Pause className="h-4 w-4" />;
case "completed":
return <CheckCircle2 className="h-4 w-4" />;
case "failed":
@@ -62,13 +79,18 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) {
const { t } = useTranslation();
const { data: liveStats } = useImportSessionStats(session.id);
const deleteSession = useDeleteImportSession();
+ const pauseSession = usePauseImportSession();
+ const resumeSession = useResumeImportSession();
- const statusLabels: Record<string, string> = {
- pending: t("settings.import_sessions.status.pending"),
- in_progress: t("settings.import_sessions.status.in_progress"),
- completed: t("settings.import_sessions.status.completed"),
- failed: t("settings.import_sessions.status.failed"),
- };
+ const statusLabels = (s: ZImportSessionStatus) =>
+ switchCase(s, {
+ staging: t("settings.import_sessions.status.staging"),
+ pending: t("settings.import_sessions.status.pending"),
+ running: t("settings.import_sessions.status.running"),
+ paused: t("settings.import_sessions.status.paused"),
+ completed: t("settings.import_sessions.status.completed"),
+ failed: t("settings.import_sessions.status.failed"),
+ });
// Use live stats if available, otherwise fallback to session stats
const stats = liveStats || session;
@@ -79,7 +101,14 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) {
100
: 0;
- const canDelete = stats.status !== "in_progress";
+ const canDelete =
+ stats.status === "completed" ||
+ stats.status === "failed" ||
+ stats.status === "paused";
+
+ const canPause = stats.status === "pending" || stats.status === "running";
+
+ const canResume = stats.status === "paused";
return (
<Card className="transition-all hover:shadow-md">
@@ -101,7 +130,7 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) {
>
{getStatusIcon(stats.status)}
<span className="ml-1 capitalize">
- {statusLabels[stats.status] ?? stats.status.replace("_", " ")}
+ {statusLabels(stats.status)}
</span>
</Badge>
</div>
@@ -213,6 +242,38 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) {
{/* Actions */}
<div className="flex items-center justify-end pt-2">
<div className="flex items-center gap-2">
+ <Button variant="outline" size="sm" asChild>
+ <Link href={`/settings/import/${session.id}`}>
+ <ExternalLink className="mr-1 h-4 w-4" />
+ {t("settings.import_sessions.view_details")}
+ </Link>
+ </Button>
+ {canPause && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ pauseSession.mutate({ importSessionId: session.id })
+ }
+ disabled={pauseSession.isPending}
+ >
+ <Pause className="mr-1 h-4 w-4" />
+ {t("settings.import_sessions.pause_session")}
+ </Button>
+ )}
+ {canResume && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ resumeSession.mutate({ importSessionId: session.id })
+ }
+ disabled={resumeSession.isPending}
+ >
+ <Play className="mr-1 h-4 w-4" />
+ {t("settings.import_sessions.resume_session")}
+ </Button>
+ )}
{canDelete && (
<ActionConfirmingDialog
title={t("settings.import_sessions.delete_dialog_title")}
diff --git a/apps/web/components/settings/ImportSessionDetail.tsx b/apps/web/components/settings/ImportSessionDetail.tsx
new file mode 100644
index 00000000..4b356eda
--- /dev/null
+++ b/apps/web/components/settings/ImportSessionDetail.tsx
@@ -0,0 +1,596 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { ActionButton } from "@/components/ui/action-button";
+import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { FullPageSpinner } from "@/components/ui/full-page-spinner";
+import { Progress } from "@/components/ui/progress";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+ useDeleteImportSession,
+ useImportSessionResults,
+ useImportSessionStats,
+ usePauseImportSession,
+ useResumeImportSession,
+} from "@/lib/hooks/useImportSessions";
+import { useTranslation } from "@/lib/i18n/client";
+import { formatDistanceToNow } from "date-fns";
+import {
+ AlertCircle,
+ ArrowLeft,
+ CheckCircle2,
+ Clock,
+ ExternalLink,
+ FileText,
+ Globe,
+ Loader2,
+ Paperclip,
+ Pause,
+ Play,
+ Trash2,
+ Upload,
+} from "lucide-react";
+import { useInView } from "react-intersection-observer";
+
+import type { ZImportSessionStatus } from "@karakeep/shared/types/importSessions";
+import { switchCase } from "@karakeep/shared/utils/switch";
+
+type FilterType =
+ | "all"
+ | "accepted"
+ | "rejected"
+ | "skipped_duplicate"
+ | "pending";
+
+type SimpleTFunction = (
+ key: string,
+ options?: Record<string, unknown>,
+) => string;
+
+interface ImportSessionResultItem {
+ id: string;
+ title: string | null;
+ url: string | null;
+ content: string | null;
+ type: string;
+ status: string;
+ result: string | null;
+ resultReason: string | null;
+ resultBookmarkId: string | null;
+}
+
+function getStatusColor(status: string) {
+ switch (status) {
+ case "staging":
+ return "bg-purple-500/10 text-purple-700 dark:text-purple-400";
+ case "pending":
+ return "bg-muted text-muted-foreground";
+ case "running":
+ return "bg-blue-500/10 text-blue-700 dark:text-blue-400";
+ case "paused":
+ return "bg-yellow-500/10 text-yellow-700 dark:text-yellow-400";
+ case "completed":
+ return "bg-green-500/10 text-green-700 dark:text-green-400";
+ case "failed":
+ return "bg-destructive/10 text-destructive";
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
+
+function getStatusIcon(status: string) {
+ switch (status) {
+ case "staging":
+ return <Upload className="h-4 w-4" />;
+ case "pending":
+ return <Clock className="h-4 w-4" />;
+ case "running":
+ return <Loader2 className="h-4 w-4 animate-spin" />;
+ case "paused":
+ return <Pause className="h-4 w-4" />;
+ case "completed":
+ return <CheckCircle2 className="h-4 w-4" />;
+ case "failed":
+ return <AlertCircle className="h-4 w-4" />;
+ default:
+ return <Clock className="h-4 w-4" />;
+ }
+}
+
+function getResultBadge(
+ status: string,
+ result: string | null,
+ t: (key: string) => string,
+) {
+ if (status === "pending") {
+ return (
+ <Badge
+ variant="secondary"
+ className="bg-muted text-muted-foreground hover:bg-muted"
+ >
+ <Clock className="mr-1 h-3 w-3" />
+ {t("settings.import_sessions.detail.result_pending")}
+ </Badge>
+ );
+ }
+ if (status === "processing") {
+ return (
+ <Badge
+ variant="secondary"
+ className="bg-blue-500/10 text-blue-700 hover:bg-blue-500/10 dark:text-blue-400"
+ >
+ <Loader2 className="mr-1 h-3 w-3 animate-spin" />
+ {t("settings.import_sessions.detail.result_processing")}
+ </Badge>
+ );
+ }
+ switch (result) {
+ case "accepted":
+ return (
+ <Badge
+ variant="secondary"
+ className="bg-green-500/10 text-green-700 hover:bg-green-500/10 dark:text-green-400"
+ >
+ <CheckCircle2 className="mr-1 h-3 w-3" />
+ {t("settings.import_sessions.detail.result_accepted")}
+ </Badge>
+ );
+ case "rejected":
+ return (
+ <Badge
+ variant="secondary"
+ className="bg-destructive/10 text-destructive hover:bg-destructive/10"
+ >
+ <AlertCircle className="mr-1 h-3 w-3" />
+ {t("settings.import_sessions.detail.result_rejected")}
+ </Badge>
+ );
+ case "skipped_duplicate":
+ return (
+ <Badge
+ variant="secondary"
+ className="bg-amber-500/10 text-amber-700 hover:bg-amber-500/10 dark:text-amber-400"
+ >
+ {t("settings.import_sessions.detail.result_skipped_duplicate")}
+ </Badge>
+ );
+ default:
+ return (
+ <Badge variant="secondary" className="bg-muted hover:bg-muted">
+ —
+ </Badge>
+ );
+ }
+}
+
+function getTypeIcon(type: string) {
+ switch (type) {
+ case "link":
+ return <Globe className="h-3 w-3" />;
+ case "text":
+ return <FileText className="h-3 w-3" />;
+ case "asset":
+ return <Paperclip className="h-3 w-3" />;
+ default:
+ return null;
+ }
+}
+
+function getTypeLabel(type: string, t: SimpleTFunction) {
+ switch (type) {
+ case "link":
+ return t("common.bookmark_types.link");
+ case "text":
+ return t("common.bookmark_types.text");
+ case "asset":
+ return t("common.bookmark_types.media");
+ default:
+ return type;
+ }
+}
+
+function getTitleDisplay(
+ item: {
+ title: string | null;
+ url: string | null;
+ content: string | null;
+ type: string;
+ },
+ noTitleLabel: string,
+) {
+ if (item.title) {
+ return item.title;
+ }
+ if (item.type === "text" && item.content) {
+ return item.content.length > 80
+ ? item.content.substring(0, 80) + "…"
+ : item.content;
+ }
+ if (item.url) {
+ try {
+ const url = new URL(item.url);
+ const display = url.hostname + url.pathname;
+ return display.length > 60 ? display.substring(0, 60) + "…" : display;
+ } catch {
+ return item.url.length > 60 ? item.url.substring(0, 60) + "…" : item.url;
+ }
+ }
+ return noTitleLabel;
+}
+
+export default function ImportSessionDetail({
+ sessionId,
+}: {
+ sessionId: string;
+}) {
+ const { t: tRaw } = useTranslation();
+ const t = tRaw as SimpleTFunction;
+ const router = useRouter();
+ const [filter, setFilter] = useState<FilterType>("all");
+
+ const { data: stats, isLoading: isStatsLoading } =
+ useImportSessionStats(sessionId);
+ const {
+ data: resultsData,
+ isLoading: isResultsLoading,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ } = useImportSessionResults(sessionId, filter);
+
+ const deleteSession = useDeleteImportSession();
+ const pauseSession = usePauseImportSession();
+ const resumeSession = useResumeImportSession();
+
+ const { ref: loadMoreRef, inView: loadMoreInView } = useInView();
+
+ useEffect(() => {
+ if (loadMoreInView && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [fetchNextPage, hasNextPage, isFetchingNextPage, loadMoreInView]);
+
+ if (isStatsLoading) {
+ return <FullPageSpinner />;
+ }
+
+ if (!stats) {
+ return null;
+ }
+
+ const items: ImportSessionResultItem[] =
+ resultsData?.pages.flatMap((page) => page.items) ?? [];
+
+ const progress =
+ stats.totalBookmarks > 0
+ ? ((stats.completedBookmarks + stats.failedBookmarks) /
+ stats.totalBookmarks) *
+ 100
+ : 0;
+
+ const canDelete =
+ stats.status === "completed" ||
+ stats.status === "failed" ||
+ stats.status === "paused";
+ const canPause = stats.status === "pending" || stats.status === "running";
+ const canResume = stats.status === "paused";
+
+ const statusLabels = (s: ZImportSessionStatus) =>
+ switchCase(s, {
+ staging: t("settings.import_sessions.status.staging"),
+ pending: t("settings.import_sessions.status.pending"),
+ running: t("settings.import_sessions.status.running"),
+ paused: t("settings.import_sessions.status.paused"),
+ completed: t("settings.import_sessions.status.completed"),
+ failed: t("settings.import_sessions.status.failed"),
+ });
+
+ const handleDelete = () => {
+ deleteSession.mutateAsync({ importSessionId: sessionId }).then(() => {
+ router.push("/settings/import");
+ });
+ };
+
+ return (
+ <div className="flex flex-col gap-6">
+ {/* Back link */}
+ <Link
+ href="/settings/import"
+ className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
+ >
+ <ArrowLeft className="h-4 w-4" />
+ {t("settings.import_sessions.detail.back_to_import")}
+ </Link>
+
+ {/* Header */}
+ <div className="rounded-md border bg-background p-4">
+ <div className="flex flex-col gap-4">
+ <div className="flex items-start justify-between">
+ <div className="flex-1">
+ <h2 className="text-lg font-medium">{stats.name}</h2>
+ <p className="mt-1 text-sm text-muted-foreground">
+ {t("settings.import_sessions.created_at", {
+ time: formatDistanceToNow(stats.createdAt, {
+ addSuffix: true,
+ }),
+ })}
+ </p>
+ </div>
+ <Badge
+ className={`${getStatusColor(stats.status)} hover:bg-inherit`}
+ >
+ {getStatusIcon(stats.status)}
+ <span className="ml-1 capitalize">
+ {statusLabels(stats.status)}
+ </span>
+ </Badge>
+ </div>
+
+ {/* Progress bar + stats */}
+ {stats.totalBookmarks > 0 && (
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <h4 className="text-sm font-medium text-muted-foreground">
+ {t("settings.import_sessions.progress")}
+ </h4>
+ <div className="flex items-center gap-2">
+ <span className="text-sm font-medium">
+ {stats.completedBookmarks + stats.failedBookmarks} /{" "}
+ {stats.totalBookmarks}
+ </span>
+ <Badge variant="outline" className="text-xs">
+ {Math.round(progress)}%
+ </Badge>
+ </div>
+ </div>
+ <Progress value={progress} className="h-3" />
+ <div className="flex flex-wrap gap-2">
+ {stats.completedBookmarks > 0 && (
+ <Badge
+ variant="secondary"
+ className="bg-green-500/10 text-green-700 hover:bg-green-500/10 dark:text-green-400"
+ >
+ <CheckCircle2 className="mr-1.5 h-3 w-3" />
+ {t("settings.import_sessions.badges.completed", {
+ count: stats.completedBookmarks,
+ })}
+ </Badge>
+ )}
+ {stats.failedBookmarks > 0 && (
+ <Badge
+ variant="secondary"
+ className="bg-destructive/10 text-destructive hover:bg-destructive/10"
+ >
+ <AlertCircle className="mr-1.5 h-3 w-3" />
+ {t("settings.import_sessions.badges.failed", {
+ count: stats.failedBookmarks,
+ })}
+ </Badge>
+ )}
+ {stats.pendingBookmarks > 0 && (
+ <Badge
+ variant="secondary"
+ className="bg-amber-500/10 text-amber-700 hover:bg-amber-500/10 dark:text-amber-400"
+ >
+ <Clock className="mr-1.5 h-3 w-3" />
+ {t("settings.import_sessions.badges.pending", {
+ count: stats.pendingBookmarks,
+ })}
+ </Badge>
+ )}
+ {stats.processingBookmarks > 0 && (
+ <Badge
+ variant="secondary"
+ className="bg-blue-500/10 text-blue-700 hover:bg-blue-500/10 dark:text-blue-400"
+ >
+ <Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
+ {t("settings.import_sessions.badges.processing", {
+ count: stats.processingBookmarks,
+ })}
+ </Badge>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* Message */}
+ {stats.message && (
+ <div className="rounded-lg border bg-muted/50 p-3 text-sm text-muted-foreground dark:bg-muted/20">
+ {stats.message}
+ </div>
+ )}
+
+ {/* Action buttons */}
+ <div className="flex items-center justify-end">
+ <div className="flex items-center gap-2">
+ {canPause && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ pauseSession.mutate({ importSessionId: sessionId })
+ }
+ disabled={pauseSession.isPending}
+ >
+ <Pause className="mr-1 h-4 w-4" />
+ {t("settings.import_sessions.pause_session")}
+ </Button>
+ )}
+ {canResume && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ resumeSession.mutate({ importSessionId: sessionId })
+ }
+ disabled={resumeSession.isPending}
+ >
+ <Play className="mr-1 h-4 w-4" />
+ {t("settings.import_sessions.resume_session")}
+ </Button>
+ )}
+ {canDelete && (
+ <ActionConfirmingDialog
+ title={t("settings.import_sessions.delete_dialog_title")}
+ description={
+ <div>
+ {t("settings.import_sessions.delete_dialog_description", {
+ name: stats.name,
+ })}
+ </div>
+ }
+ actionButton={(setDialogOpen) => (
+ <Button
+ variant="destructive"
+ onClick={() => {
+ handleDelete();
+ setDialogOpen(false);
+ }}
+ disabled={deleteSession.isPending}
+ >
+ {t("settings.import_sessions.delete_session")}
+ </Button>
+ )}
+ >
+ <Button
+ variant="destructive"
+ size="sm"
+ disabled={deleteSession.isPending}
+ >
+ <Trash2 className="mr-1 h-4 w-4" />
+ {t("actions.delete")}
+ </Button>
+ </ActionConfirmingDialog>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* Filter tabs + Results table */}
+ <div className="rounded-md border bg-background p-4">
+ <Tabs
+ value={filter}
+ onValueChange={(v) => setFilter(v as FilterType)}
+ className="w-full"
+ >
+ <TabsList className="mb-4 flex w-full flex-wrap">
+ <TabsTrigger value="all">
+ {t("settings.import_sessions.detail.filter_all")}
+ </TabsTrigger>
+ <TabsTrigger value="accepted">
+ {t("settings.import_sessions.detail.filter_accepted")}
+ </TabsTrigger>
+ <TabsTrigger value="rejected">
+ {t("settings.import_sessions.detail.filter_rejected")}
+ </TabsTrigger>
+ <TabsTrigger value="skipped_duplicate">
+ {t("settings.import_sessions.detail.filter_duplicates")}
+ </TabsTrigger>
+ <TabsTrigger value="pending">
+ {t("settings.import_sessions.detail.filter_pending")}
+ </TabsTrigger>
+ </TabsList>
+ </Tabs>
+
+ {isResultsLoading ? (
+ <FullPageSpinner />
+ ) : items.length === 0 ? (
+ <p className="rounded-md bg-muted p-4 text-center text-sm text-muted-foreground">
+ {t("settings.import_sessions.detail.no_results")}
+ </p>
+ ) : (
+ <div className="flex flex-col gap-2">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>
+ {t("settings.import_sessions.detail.table_title")}
+ </TableHead>
+ <TableHead className="w-[80px]">
+ {t("settings.import_sessions.detail.table_type")}
+ </TableHead>
+ <TableHead className="w-[120px]">
+ {t("settings.import_sessions.detail.table_result")}
+ </TableHead>
+ <TableHead>
+ {t("settings.import_sessions.detail.table_reason")}
+ </TableHead>
+ <TableHead className="w-[100px]">
+ {t("settings.import_sessions.detail.table_bookmark")}
+ </TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {items.map((item) => (
+ <TableRow key={item.id}>
+ <TableCell className="max-w-[300px] truncate font-medium">
+ {getTitleDisplay(
+ item,
+ t("settings.import_sessions.detail.no_title"),
+ )}
+ </TableCell>
+ <TableCell>
+ <Badge
+ variant="outline"
+ className="flex w-fit items-center gap-1 text-xs"
+ >
+ {getTypeIcon(item.type)}
+ {getTypeLabel(item.type, t)}
+ </Badge>
+ </TableCell>
+ <TableCell>
+ {getResultBadge(item.status, item.result, t)}
+ </TableCell>
+ <TableCell className="max-w-[200px] truncate text-sm text-muted-foreground">
+ {item.resultReason || "—"}
+ </TableCell>
+ <TableCell>
+ {item.resultBookmarkId ? (
+ <Link
+ href={`/dashboard/preview/${item.resultBookmarkId}`}
+ className="flex items-center gap-1 text-sm text-primary hover:text-primary/80"
+ prefetch={false}
+ >
+ <ExternalLink className="h-3 w-3" />
+ {t("settings.import_sessions.detail.view_bookmark")}
+ </Link>
+ ) : (
+ <span className="text-sm text-muted-foreground">—</span>
+ )}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ {hasNextPage && (
+ <div className="flex justify-center">
+ <ActionButton
+ ref={loadMoreRef}
+ ignoreDemoMode={true}
+ loading={isFetchingNextPage}
+ onClick={() => fetchNextPage()}
+ variant="ghost"
+ >
+ {t("settings.import_sessions.detail.load_more")}
+ </ActionButton>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/components/settings/ReaderSettings.tsx b/apps/web/components/settings/ReaderSettings.tsx
new file mode 100644
index 00000000..d694bf02
--- /dev/null
+++ b/apps/web/components/settings/ReaderSettings.tsx
@@ -0,0 +1,311 @@
+"use client";
+
+import { useState } from "react";
+import { toast } from "@/components/ui/sonner";
+import { useClientConfig } from "@/lib/clientConfig";
+import { useTranslation } from "@/lib/i18n/client";
+import { useReaderSettings } from "@/lib/readerSettings";
+import {
+ AlertTriangle,
+ BookOpen,
+ ChevronDown,
+ Laptop,
+ RotateCcw,
+} from "lucide-react";
+
+import {
+ formatFontSize,
+ formatLineHeight,
+ READER_DEFAULTS,
+ READER_FONT_FAMILIES,
+ READER_SETTING_CONSTRAINTS,
+} from "@karakeep/shared/types/readers";
+
+import { Alert, AlertDescription } from "../ui/alert";
+import { Button } from "../ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "../ui/card";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "../ui/collapsible";
+import { Label } from "../ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../ui/select";
+import { Slider } from "../ui/slider";
+
+export default function ReaderSettings() {
+ const { t } = useTranslation();
+ const clientConfig = useClientConfig();
+ const {
+ settings,
+ serverSettings,
+ localOverrides,
+ hasLocalOverrides,
+ clearServerDefaults,
+ clearLocalOverrides,
+ updateServerSetting,
+ } = useReaderSettings();
+
+ // Local state for collapsible
+ const [isOpen, setIsOpen] = useState(false);
+
+ // Local state for slider dragging (null = not dragging, use server value)
+ const [draggingFontSize, setDraggingFontSize] = useState<number | null>(null);
+ const [draggingLineHeight, setDraggingLineHeight] = useState<number | null>(
+ null,
+ );
+
+ const hasServerSettings =
+ serverSettings.fontSize !== null ||
+ serverSettings.lineHeight !== null ||
+ serverSettings.fontFamily !== null;
+
+ const handleClearDefaults = () => {
+ clearServerDefaults();
+ toast({ description: t("settings.info.reader_settings.defaults_cleared") });
+ };
+
+ const handleClearLocalOverrides = () => {
+ clearLocalOverrides();
+ toast({
+ description: t("settings.info.reader_settings.local_overrides_cleared"),
+ });
+ };
+
+ // Format local override for display
+ const formatLocalOverride = (
+ key: "fontSize" | "lineHeight" | "fontFamily",
+ ) => {
+ const value = localOverrides[key];
+ if (value === undefined) return null;
+ if (key === "fontSize") return formatFontSize(value as number);
+ if (key === "lineHeight") return formatLineHeight(value as number);
+ if (key === "fontFamily") {
+ switch (value) {
+ case "serif":
+ return t("settings.info.reader_settings.serif");
+ case "sans":
+ return t("settings.info.reader_settings.sans");
+ case "mono":
+ return t("settings.info.reader_settings.mono");
+ }
+ }
+ return String(value);
+ };
+
+ return (
+ <Collapsible open={isOpen} onOpenChange={setIsOpen}>
+ <Card>
+ <CardHeader>
+ <CollapsibleTrigger className="flex w-full items-center justify-between [&[data-state=open]>svg]:rotate-180">
+ <div className="flex flex-col items-start gap-1 text-left">
+ <CardTitle className="flex items-center gap-2 text-xl">
+ <BookOpen className="h-5 w-5" />
+ {t("settings.info.reader_settings.title")}
+ </CardTitle>
+ <CardDescription>
+ {t("settings.info.reader_settings.description")}
+ </CardDescription>
+ </div>
+ <ChevronDown className="h-5 w-5 shrink-0 transition-transform duration-200" />
+ </CollapsibleTrigger>
+ </CardHeader>
+ <CollapsibleContent>
+ <CardContent className="space-y-6">
+ {/* Local Overrides Warning */}
+ {hasLocalOverrides && (
+ <Alert>
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription className="flex flex-col gap-3">
+ <div>
+ <p className="font-medium">
+ {t("settings.info.reader_settings.local_overrides_title")}
+ </p>
+ <p className="mt-1 text-sm text-muted-foreground">
+ {t(
+ "settings.info.reader_settings.local_overrides_description",
+ )}
+ </p>
+ <ul className="mt-2 text-sm text-muted-foreground">
+ {localOverrides.fontFamily !== undefined && (
+ <li>
+ {t("settings.info.reader_settings.font_family")}:{" "}
+ {formatLocalOverride("fontFamily")}
+ </li>
+ )}
+ {localOverrides.fontSize !== undefined && (
+ <li>
+ {t("settings.info.reader_settings.font_size")}:{" "}
+ {formatLocalOverride("fontSize")}
+ </li>
+ )}
+ {localOverrides.lineHeight !== undefined && (
+ <li>
+ {t("settings.info.reader_settings.line_height")}:{" "}
+ {formatLocalOverride("lineHeight")}
+ </li>
+ )}
+ </ul>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleClearLocalOverrides}
+ className="w-fit"
+ >
+ <Laptop className="mr-2 h-4 w-4" />
+ {t("settings.info.reader_settings.clear_local_overrides")}
+ </Button>
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* Font Family */}
+ <div className="space-y-2">
+ <Label className="text-sm font-medium">
+ {t("settings.info.reader_settings.font_family")}
+ </Label>
+ <Select
+ disabled={!!clientConfig.demoMode}
+ value={serverSettings.fontFamily ?? "not-set"}
+ onValueChange={(value) => {
+ if (value !== "not-set") {
+ updateServerSetting({
+ fontFamily: value as "serif" | "sans" | "mono",
+ });
+ }
+ }}
+ >
+ <SelectTrigger className="h-11">
+ <SelectValue
+ placeholder={t("settings.info.reader_settings.not_set")}
+ />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="not-set" disabled>
+ {t("settings.info.reader_settings.not_set")} (
+ {t("common.default")}: {READER_DEFAULTS.fontFamily})
+ </SelectItem>
+ <SelectItem value="serif">
+ {t("settings.info.reader_settings.serif")}
+ </SelectItem>
+ <SelectItem value="sans">
+ {t("settings.info.reader_settings.sans")}
+ </SelectItem>
+ <SelectItem value="mono">
+ {t("settings.info.reader_settings.mono")}
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ {serverSettings.fontFamily === null && (
+ <p className="text-xs text-muted-foreground">
+ {t("settings.info.reader_settings.using_default")}:{" "}
+ {READER_DEFAULTS.fontFamily}
+ </p>
+ )}
+ </div>
+
+ {/* Font Size */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <Label className="text-sm font-medium">
+ {t("settings.info.reader_settings.font_size")}
+ </Label>
+ <span className="text-sm text-muted-foreground">
+ {formatFontSize(draggingFontSize ?? settings.fontSize)}
+ {serverSettings.fontSize === null &&
+ draggingFontSize === null &&
+ ` (${t("common.default").toLowerCase()})`}
+ </span>
+ </div>
+ <Slider
+ disabled={!!clientConfig.demoMode}
+ value={[draggingFontSize ?? settings.fontSize]}
+ onValueChange={([value]) => setDraggingFontSize(value)}
+ onValueCommit={([value]) => {
+ updateServerSetting({ fontSize: value });
+ setDraggingFontSize(null);
+ }}
+ max={READER_SETTING_CONSTRAINTS.fontSize.max}
+ min={READER_SETTING_CONSTRAINTS.fontSize.min}
+ step={READER_SETTING_CONSTRAINTS.fontSize.step}
+ />
+ </div>
+
+ {/* Line Height */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <Label className="text-sm font-medium">
+ {t("settings.info.reader_settings.line_height")}
+ </Label>
+ <span className="text-sm text-muted-foreground">
+ {formatLineHeight(draggingLineHeight ?? settings.lineHeight)}
+ {serverSettings.lineHeight === null &&
+ draggingLineHeight === null &&
+ ` (${t("common.default").toLowerCase()})`}
+ </span>
+ </div>
+ <Slider
+ disabled={!!clientConfig.demoMode}
+ value={[draggingLineHeight ?? settings.lineHeight]}
+ onValueChange={([value]) => setDraggingLineHeight(value)}
+ onValueCommit={([value]) => {
+ updateServerSetting({ lineHeight: value });
+ setDraggingLineHeight(null);
+ }}
+ max={READER_SETTING_CONSTRAINTS.lineHeight.max}
+ min={READER_SETTING_CONSTRAINTS.lineHeight.min}
+ step={READER_SETTING_CONSTRAINTS.lineHeight.step}
+ />
+ </div>
+
+ {/* Clear Defaults Button */}
+ {hasServerSettings && (
+ <Button
+ variant="outline"
+ onClick={handleClearDefaults}
+ className="w-full"
+ disabled={!!clientConfig.demoMode}
+ >
+ <RotateCcw className="mr-2 h-4 w-4" />
+ {t("settings.info.reader_settings.clear_defaults")}
+ </Button>
+ )}
+
+ {/* Preview */}
+ <div className="rounded-lg border p-4">
+ <p className="mb-2 text-sm font-medium text-muted-foreground">
+ {t("settings.info.reader_settings.preview")}
+ </p>
+ <p
+ style={{
+ fontFamily: READER_FONT_FAMILIES[settings.fontFamily],
+ fontSize: `${draggingFontSize ?? settings.fontSize}px`,
+ lineHeight: draggingLineHeight ?? settings.lineHeight,
+ }}
+ >
+ {t("settings.info.reader_settings.preview_text")}
+ <br />
+ {t("settings.info.reader_settings.preview_text")}
+ <br />
+ {t("settings.info.reader_settings.preview_text")}
+ </p>
+ </div>
+ </CardContent>
+ </CollapsibleContent>
+ </Card>
+ </Collapsible>
+ );
+}
diff --git a/apps/web/components/settings/RegenerateApiKey.tsx b/apps/web/components/settings/RegenerateApiKey.tsx
index 1c034026..943d21ef 100644
--- a/apps/web/components/settings/RegenerateApiKey.tsx
+++ b/apps/web/components/settings/RegenerateApiKey.tsx
@@ -14,11 +14,13 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
-import { toast } from "@/components/ui/use-toast";
+import { toast } from "@/components/ui/sonner";
import { useTranslation } from "@/lib/i18n/client";
-import { api } from "@/lib/trpc";
+import { useMutation } from "@tanstack/react-query";
import { RefreshCcw } from "lucide-react";
+import { useTRPC } from "@karakeep/shared-react/trpc";
+
import ApiKeySuccess from "./ApiKeySuccess";
export default function RegenerateApiKey({
@@ -28,25 +30,28 @@ export default function RegenerateApiKey({
id: string;
name: string;
}) {
+ const api = useTRPC();
const { t } = useTranslation();
const router = useRouter();
const [key, setKey] = useState<string | undefined>(undefined);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
- const mutator = api.apiKeys.regenerate.useMutation({
- onSuccess: (resp) => {
- setKey(resp.key);
- router.refresh();
- },
- onError: () => {
- toast({
- description: t("common.something_went_wrong"),
- variant: "destructive",
- });
- setDialogOpen(false);
- },
- });
+ const mutator = useMutation(
+ api.apiKeys.regenerate.mutationOptions({
+ onSuccess: (resp) => {
+ setKey(resp.key);
+ router.refresh();
+ },
+ onError: () => {
+ toast({
+ description: t("common.something_went_wrong"),
+ variant: "destructive",
+ });
+ setDialogOpen(false);
+ },
+ }),
+ );
const handleRegenerate = () => {
mutator.mutate({ id });
diff --git a/apps/web/components/settings/SubscriptionSettings.tsx b/apps/web/components/settings/SubscriptionSettings.tsx
index 53f1caf4..48ab1258 100644
--- a/apps/web/components/settings/SubscriptionSettings.tsx
+++ b/apps/web/components/settings/SubscriptionSettings.tsx
@@ -1,10 +1,13 @@
"use client";
import { useEffect } from "react";
+import { toast } from "@/components/ui/sonner";
import { useTranslation } from "@/lib/i18n/client";
-import { api } from "@/lib/trpc";
+import { useMutation, useQuery } from "@tanstack/react-query";
import { CreditCard, Loader2 } from "lucide-react";
+import { useTRPC } from "@karakeep/shared-react/trpc";
+
import { Alert, AlertDescription } from "../ui/alert";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
@@ -16,27 +19,29 @@ import {
CardTitle,
} from "../ui/card";
import { Skeleton } from "../ui/skeleton";
-import { toast } from "../ui/use-toast";
export default function SubscriptionSettings() {
+ const api = useTRPC();
const { t } = useTranslation();
const {
data: subscriptionStatus,
refetch,
isLoading: isQueryLoading,
- } = api.subscriptions.getSubscriptionStatus.useQuery();
+ } = useQuery(api.subscriptions.getSubscriptionStatus.queryOptions());
- const { data: subscriptionPrice } =
- api.subscriptions.getSubscriptionPrice.useQuery();
+ const { data: subscriptionPrice } = useQuery(
+ api.subscriptions.getSubscriptionPrice.queryOptions(),
+ );
- const { mutate: syncStripeState } =
- api.subscriptions.syncWithStripe.useMutation({
+ const { mutate: syncStripeState } = useMutation(
+ api.subscriptions.syncWithStripe.mutationOptions({
onSuccess: () => {
refetch();
},
- });
- const createCheckoutSession =
- api.subscriptions.createCheckoutSession.useMutation({
+ }),
+ );
+ const createCheckoutSession = useMutation(
+ api.subscriptions.createCheckoutSession.mutationOptions({
onSuccess: (resp) => {
if (resp.url) {
window.location.href = resp.url;
@@ -48,9 +53,10 @@ export default function SubscriptionSettings() {
variant: "destructive",
});
},
- });
- const createPortalSession = api.subscriptions.createPortalSession.useMutation(
- {
+ }),
+ );
+ const createPortalSession = useMutation(
+ api.subscriptions.createPortalSession.mutationOptions({
onSuccess: (resp) => {
if (resp.url) {
window.location.href = resp.url;
@@ -62,7 +68,7 @@ export default function SubscriptionSettings() {
variant: "destructive",
});
},
- },
+ }),
);
const isLoading =
diff --git a/apps/web/components/settings/UserAvatar.tsx b/apps/web/components/settings/UserAvatar.tsx
new file mode 100644
index 00000000..6baff7c2
--- /dev/null
+++ b/apps/web/components/settings/UserAvatar.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import type { ChangeEvent } from "react";
+import { useRef } from "react";
+import { ActionButton } from "@/components/ui/action-button";
+import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog";
+import { toast } from "@/components/ui/sonner";
+import { UserAvatar as UserAvatarImage } from "@/components/ui/user-avatar";
+import useUpload from "@/lib/hooks/upload-file";
+import { useTranslation } from "@/lib/i18n/client";
+import { Image as ImageIcon, Upload, User, X } from "lucide-react";
+
+import {
+ useUpdateUserAvatar,
+ useWhoAmI,
+} from "@karakeep/shared-react/hooks/users";
+
+import { Button } from "../ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
+
+export default function UserAvatar() {
+ const { t } = useTranslation();
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const whoami = useWhoAmI();
+ const image = whoami.data?.image ?? null;
+
+ const updateAvatar = useUpdateUserAvatar({
+ onError: () => {
+ toast({
+ description: t("common.something_went_wrong"),
+ variant: "destructive",
+ });
+ },
+ });
+
+ const upload = useUpload({
+ onSuccess: async (resp) => {
+ try {
+ await updateAvatar.mutateAsync({ assetId: resp.assetId });
+ toast({
+ description: t("settings.info.avatar.updated"),
+ });
+ } catch {
+ // handled in onError
+ }
+ },
+ onError: (err) => {
+ toast({
+ description: err.error,
+ variant: "destructive",
+ });
+ },
+ });
+
+ const isBusy = upload.isPending || updateAvatar.isPending;
+
+ const handleSelectFile = () => {
+ fileInputRef.current?.click();
+ };
+
+ const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0];
+ if (!file) {
+ return;
+ }
+ upload.mutate(file);
+ event.target.value = "";
+ };
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2 text-xl">
+ <ImageIcon className="h-5 w-5" />
+ {t("settings.info.avatar.title")}
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <p className="text-sm text-muted-foreground">
+ {t("settings.info.avatar.description")}
+ </p>
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
+ <div className="flex items-center gap-4">
+ <div className="flex size-16 items-center justify-center overflow-hidden rounded-full border bg-muted">
+ <UserAvatarImage
+ image={image}
+ name={t("settings.info.avatar.title")}
+ fallback={<User className="h-7 w-7 text-muted-foreground" />}
+ className="h-full w-full"
+ />
+ </div>
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept="image/*"
+ className="hidden"
+ onChange={handleFileChange}
+ />
+ <ActionButton
+ type="button"
+ variant="secondary"
+ onClick={handleSelectFile}
+ loading={upload.isPending}
+ disabled={isBusy}
+ >
+ <Upload className="mr-2 h-4 w-4" />
+ {image
+ ? t("settings.info.avatar.change")
+ : t("settings.info.avatar.upload")}
+ </ActionButton>
+ </div>
+ <ActionConfirmingDialog
+ title={t("settings.info.avatar.remove_confirm_title")}
+ description={
+ <p>{t("settings.info.avatar.remove_confirm_description")}</p>
+ }
+ actionButton={(setDialogOpen) => (
+ <ActionButton
+ type="button"
+ variant="destructive"
+ loading={updateAvatar.isPending}
+ onClick={() =>
+ updateAvatar.mutate(
+ { assetId: null },
+ {
+ onSuccess: () => {
+ toast({
+ description: t("settings.info.avatar.removed"),
+ });
+ setDialogOpen(false);
+ },
+ },
+ )
+ }
+ >
+ {t("settings.info.avatar.remove")}
+ </ActionButton>
+ )}
+ >
+ <Button type="button" variant="outline" disabled={!image || isBusy}>
+ <X className="mr-2 h-4 w-4" />
+ {t("settings.info.avatar.remove")}
+ </Button>
+ </ActionConfirmingDialog>
+ </div>
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/apps/web/components/settings/UserOptions.tsx b/apps/web/components/settings/UserOptions.tsx
index 0df1085e..763695c5 100644
--- a/apps/web/components/settings/UserOptions.tsx
+++ b/apps/web/components/settings/UserOptions.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
+import { toast } from "@/components/ui/sonner";
import { useClientConfig } from "@/lib/clientConfig";
import { useTranslation } from "@/lib/i18n/client";
import { useInterfaceLang } from "@/lib/userLocalSettings/bookmarksLayout";
@@ -28,7 +29,6 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
-import { toast } from "../ui/use-toast";
const LanguageSelect = () => {
const lang = useInterfaceLang();
diff --git a/apps/web/components/settings/WebhookSettings.tsx b/apps/web/components/settings/WebhookSettings.tsx
index 8efd3ba6..7a05b9e6 100644
--- a/apps/web/components/settings/WebhookSettings.tsx
+++ b/apps/web/components/settings/WebhookSettings.tsx
@@ -12,10 +12,10 @@ import {
} from "@/components/ui/form";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
import { Input } from "@/components/ui/input";
-import { toast } from "@/components/ui/use-toast";
+import { toast } from "@/components/ui/sonner";
import { useTranslation } from "@/lib/i18n/client";
-import { api } from "@/lib/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Edit,
KeyRound,
@@ -28,6 +28,7 @@ import {
import { useForm } from "react-hook-form";
import { z } from "zod";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import {
zNewWebhookSchema,
zUpdateWebhookSchema,
@@ -56,9 +57,10 @@ import {
import { WebhookEventSelector } from "./WebhookEventSelector";
export function WebhooksEditorDialog() {
+ const api = useTRPC();
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
- const apiUtils = api.useUtils();
+ const queryClient = useQueryClient();
const form = useForm<z.infer<typeof zNewWebhookSchema>>({
resolver: zodResolver(zNewWebhookSchema),
@@ -75,16 +77,17 @@ export function WebhooksEditorDialog() {
}
}, [open]);
- const { mutateAsync: createWebhook, isPending: isCreating } =
- api.webhooks.create.useMutation({
+ const { mutateAsync: createWebhook, isPending: isCreating } = useMutation(
+ api.webhooks.create.mutationOptions({
onSuccess: () => {
toast({
description: "Webhook has been created!",
});
- apiUtils.webhooks.list.invalidate();
+ queryClient.invalidateQueries(api.webhooks.list.pathFilter());
setOpen(false);
},
- });
+ }),
+ );
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -179,8 +182,9 @@ export function WebhooksEditorDialog() {
}
export function EditWebhookDialog({ webhook }: { webhook: ZWebhook }) {
+ const api = useTRPC();
const { t } = useTranslation();
- const apiUtils = api.useUtils();
+ const queryClient = useQueryClient();
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
if (open) {
@@ -191,16 +195,17 @@ export function EditWebhookDialog({ webhook }: { webhook: ZWebhook }) {
});
}
}, [open]);
- const { mutateAsync: updateWebhook, isPending: isUpdating } =
- api.webhooks.update.useMutation({
+ const { mutateAsync: updateWebhook, isPending: isUpdating } = useMutation(
+ api.webhooks.update.mutationOptions({
onSuccess: () => {
toast({
description: "Webhook has been updated!",
});
setOpen(false);
- apiUtils.webhooks.list.invalidate();
+ queryClient.invalidateQueries(api.webhooks.list.pathFilter());
},
- });
+ }),
+ );
const updateSchema = zUpdateWebhookSchema.required({
events: true,
url: true,
@@ -302,8 +307,9 @@ export function EditWebhookDialog({ webhook }: { webhook: ZWebhook }) {
}
export function EditTokenDialog({ webhook }: { webhook: ZWebhook }) {
+ const api = useTRPC();
const { t } = useTranslation();
- const apiUtils = api.useUtils();
+ const queryClient = useQueryClient();
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
if (open) {
@@ -331,16 +337,17 @@ export function EditTokenDialog({ webhook }: { webhook: ZWebhook }) {
},
});
- const { mutateAsync: updateWebhook, isPending: isUpdating } =
- api.webhooks.update.useMutation({
+ const { mutateAsync: updateWebhook, isPending: isUpdating } = useMutation(
+ api.webhooks.update.mutationOptions({
onSuccess: () => {
toast({
description: "Webhook token has been updated!",
});
setOpen(false);
- apiUtils.webhooks.list.invalidate();
+ queryClient.invalidateQueries(api.webhooks.list.pathFilter());
},
- });
+ }),
+ );
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -432,17 +439,19 @@ export function EditTokenDialog({ webhook }: { webhook: ZWebhook }) {
}
export function WebhookRow({ webhook }: { webhook: ZWebhook }) {
+ const api = useTRPC();
const { t } = useTranslation();
- const apiUtils = api.useUtils();
- const { mutate: deleteWebhook, isPending: isDeleting } =
- api.webhooks.delete.useMutation({
+ const queryClient = useQueryClient();
+ const { mutate: deleteWebhook, isPending: isDeleting } = useMutation(
+ api.webhooks.delete.mutationOptions({
onSuccess: () => {
toast({
description: "Webhook has been deleted!",
});
- apiUtils.webhooks.list.invalidate();
+ queryClient.invalidateQueries(api.webhooks.list.pathFilter());
},
- });
+ }),
+ );
return (
<TableRow>
@@ -479,8 +488,11 @@ export function WebhookRow({ webhook }: { webhook: ZWebhook }) {
}
export default function WebhookSettings() {
+ const api = useTRPC();
const { t } = useTranslation();
- const { data: webhooks, isLoading } = api.webhooks.list.useQuery();
+ const { data: webhooks, isLoading } = useQuery(
+ api.webhooks.list.queryOptions(),
+ );
return (
<div className="rounded-md border bg-background p-4">
<div className="flex flex-col gap-2">