diff options
Diffstat (limited to '')
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'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'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"> |
