aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/settings
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/components/settings')
-rw-r--r--apps/web/components/settings/FeedSettings.tsx72
-rw-r--r--apps/web/components/settings/ImportExport.tsx155
-rw-r--r--apps/web/components/settings/UserOptions.tsx148
3 files changed, 309 insertions, 66 deletions
diff --git a/apps/web/components/settings/FeedSettings.tsx b/apps/web/components/settings/FeedSettings.tsx
index ff8590c9..fa019cf6 100644
--- a/apps/web/components/settings/FeedSettings.tsx
+++ b/apps/web/components/settings/FeedSettings.tsx
@@ -13,6 +13,7 @@ import {
} from "@/components/ui/form";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
import { Input } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
import { toast } from "@/components/ui/use-toast";
import { useTranslation } from "@/lib/i18n/client";
import { api } from "@/lib/trpc";
@@ -70,6 +71,7 @@ export function FeedsEditorDialog() {
defaultValues: {
name: "",
url: "",
+ enabled: true,
},
});
@@ -199,12 +201,16 @@ export function EditFeedDialog({ feed }: { feed: ZFeed }) {
});
return (
<Dialog open={open} onOpenChange={setOpen}>
- <DialogTrigger asChild>
- <Button variant="secondary">
- <Edit className="mr-2 size-4" />
- {t("actions.edit")}
- </Button>
- </DialogTrigger>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <DialogTrigger asChild>
+ <Button variant="ghost">
+ <Edit className="size-4" />
+ </Button>
+ </DialogTrigger>
+ </TooltipTrigger>
+ <TooltipContent>{t("actions.edit")}</TooltipContent>
+ </Tooltip>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Feed</DialogTitle>
@@ -309,6 +315,27 @@ export function FeedRow({ feed }: { feed: ZFeed }) {
},
});
+ 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 handleToggle = (checked: boolean) => {
+ updateFeedEnabled({ feedId: feed.id, enabled: checked });
+ };
+
return (
<TableRow>
<TableCell>
@@ -319,7 +346,12 @@ export function FeedRow({ feed }: { feed: ZFeed }) {
{feed.name}
</Link>
</TableCell>
- <TableCell>{feed.url}</TableCell>
+ <TableCell
+ className="max-w-64 overflow-clip text-ellipsis"
+ title={feed.url}
+ >
+ {feed.url}
+ </TableCell>
<TableCell>{feed.lastFetchedAt?.toLocaleString()}</TableCell>
<TableCell>
{feed.lastFetchedStatus === "success" ? (
@@ -337,16 +369,21 @@ export function FeedRow({ feed }: { feed: ZFeed }) {
)}
</TableCell>
<TableCell className="flex items-center gap-2">
+ <Switch checked={feed.enabled} onCheckedChange={handleToggle} />
<EditFeedDialog feed={feed} />
- <ActionButton
- loading={isFetching}
- variant="secondary"
- className="items-center"
- onClick={() => fetchNow({ feedId: feed.id })}
- >
- <ArrowDownToLine className="mr-2 size-4" />
- {t("actions.fetch_now")}
- </ActionButton>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <ActionButton
+ loading={isFetching}
+ variant="ghost"
+ className="items-center"
+ onClick={() => fetchNow({ feedId: feed.id })}
+ >
+ <ArrowDownToLine className="size-4" />
+ </ActionButton>
+ </TooltipTrigger>
+ <TooltipContent>{t("actions.fetch_now")}</TooltipContent>
+ </Tooltip>
<ActionConfirmingDialog
title={`Delete Feed "${feed.name}"?`}
description={`Are you sure you want to delete the feed "${feed.name}"?`}
@@ -364,8 +401,7 @@ export function FeedRow({ feed }: { feed: ZFeed }) {
)}
>
<Button variant="destructive" disabled={isDeleting}>
- <Trash2 className="mr-2 size-4" />
- {t("actions.delete")}
+ <Trash2 className="size-4" />
</Button>
</ActionConfirmingDialog>
</TableCell>
diff --git a/apps/web/components/settings/ImportExport.tsx b/apps/web/components/settings/ImportExport.tsx
index 43b934a6..35c2b88f 100644
--- a/apps/web/components/settings/ImportExport.tsx
+++ b/apps/web/components/settings/ImportExport.tsx
@@ -6,6 +6,13 @@ import { useRouter } from "next/navigation";
import { buttonVariants } from "@/components/ui/button";
import FilePickerButton from "@/components/ui/file-picker-button";
import { Progress } from "@/components/ui/progress";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
import { toast } from "@/components/ui/use-toast";
import { useTranslation } from "@/lib/i18n/client";
import {
@@ -20,7 +27,6 @@ import {
} from "@/lib/importBookmarkParser";
import { cn } from "@/lib/utils";
import { useMutation } from "@tanstack/react-query";
-import { TRPCClientError } from "@trpc/client";
import { Download, Upload } from "lucide-react";
import {
@@ -63,6 +69,8 @@ function ImportCard({
function ExportButton() {
const { t } = useTranslation();
+ const [format, setFormat] = useState<"json" | "netscape">("json");
+
return (
<Card className="transition-all hover:shadow-md">
<CardContent className="flex items-center gap-3 p-4">
@@ -72,9 +80,21 @@ function ExportButton() {
<div className="flex-1">
<h3 className="font-medium">Export File</h3>
<p>{t("settings.import.export_links_and_notes")}</p>
+ <Select
+ value={format}
+ onValueChange={(value) => setFormat(value as "json" | "netscape")}
+ >
+ <SelectTrigger className="mt-2 w-[180px]">
+ <SelectValue placeholder="Format" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="json">JSON (Karakeep format)</SelectItem>
+ <SelectItem value="netscape">HTML (Netscape format)</SelectItem>
+ </SelectContent>
+ </Select>
</div>
<Link
- href="/api/bookmarks/export"
+ href={`/api/bookmarks/export?format=${format}`}
className={cn(
buttonVariants({ variant: "default", size: "sm" }),
"flex items-center gap-2",
@@ -104,7 +124,7 @@ export function ImportExportRow() {
const { mutateAsync: parseAndCreateBookmark } = useMutation({
mutationFn: async (toImport: {
bookmark: ParsedBookmark;
- listId: string;
+ listIds: string[];
}) => {
const bookmark = toImport.bookmark;
if (bookmark.content === undefined) {
@@ -116,6 +136,7 @@ export function ImportExportRow() {
? new Date(bookmark.addDate * 1000)
: undefined,
note: bookmark.notes,
+ archived: bookmark.archived,
...(bookmark.content.type === BookmarkTypes.LINK
? {
type: BookmarkTypes.LINK,
@@ -129,20 +150,14 @@ export function ImportExportRow() {
await Promise.all([
// Add to import list
- addToList({
- bookmarkId: created.id,
- listId: toImport.listId,
- }).catch((e) => {
- if (
- e instanceof TRPCClientError &&
- e.message.includes("already in the list")
- ) {
- /* empty */
- } else {
- throw e;
- }
- }),
-
+ ...[
+ toImport.listIds.map((listId) =>
+ addToList({
+ bookmarkId: created.id,
+ listId,
+ }),
+ ),
+ ],
// Update tags
bookmark.tags.length > 0
? updateTags({
@@ -192,7 +207,7 @@ export function ImportExportRow() {
return;
}
- const importList = await createList({
+ const rootList = await createList({
name: t("settings.import.imported_bookmarks"),
icon: "⬆️",
});
@@ -201,33 +216,83 @@ export function ImportExportRow() {
setImportProgress({ done: 0, total: finalBookmarksToImport.length });
+ // Precreate folder lists
+ const allRequiredPaths = new Set<string>();
+ // collect the paths of all bookmarks that have non-empty paths
+ for (const bookmark of finalBookmarksToImport) {
+ for (const path of bookmark.paths) {
+ if (path && path.length > 0) {
+ // We need every prefix of the path for the hierarchy
+ for (let i = 1; i <= path.length; i++) {
+ const subPath = path.slice(0, i);
+ const pathKey = subPath.join("/");
+ allRequiredPaths.add(pathKey);
+ }
+ }
+ }
+ }
+
+ // Convert to array and sort by depth (so that parent paths come first)
+ const allRequiredPathsArray = Array.from(allRequiredPaths).sort(
+ (a, b) => a.split("/").length - b.split("/").length,
+ );
+
+ const pathMap: Record<string, string> = {};
+
+ // Root list is the parent for top-level folders
+ // Represent root as empty string
+ pathMap[""] = rootList.id;
+
+ for (const pathKey of allRequiredPathsArray) {
+ const parts = pathKey.split("/");
+ const parentKey = parts.slice(0, -1).join("/");
+ const parentId = pathMap[parentKey] || rootList.id;
+
+ const folderName = parts[parts.length - 1];
+ // Create the list
+ const folderList = await createList({
+ name: folderName,
+ parentId: parentId,
+ icon: "📁",
+ });
+ pathMap[pathKey] = folderList.id;
+ }
+
const importPromises = finalBookmarksToImport.map(
- (bookmark) => () =>
- parseAndCreateBookmark({
- bookmark: bookmark,
- listId: importList.id,
- }).then(
- (value) => {
- setImportProgress((prev) => {
- const newDone = (prev?.done ?? 0) + 1;
- return {
- done: newDone,
- total: finalBookmarksToImport.length,
- };
- });
- return { status: "fulfilled" as const, value };
- },
- () => {
- setImportProgress((prev) => {
- const newDone = (prev?.done ?? 0) + 1;
- return {
- done: newDone,
- total: finalBookmarksToImport.length,
- };
- });
- return { status: "rejected" as const };
- },
- ),
+ (bookmark) => async () => {
+ // Determine the target list ids
+ const listIds = bookmark.paths.map(
+ (path) => pathMap[path.join("/")] || rootList.id,
+ );
+ if (listIds.length === 0) {
+ listIds.push(rootList.id);
+ }
+
+ try {
+ const created = await parseAndCreateBookmark({
+ bookmark: bookmark,
+ listIds,
+ });
+
+ setImportProgress((prev) => {
+ const newDone = (prev?.done ?? 0) + 1;
+ return {
+ done: newDone,
+ total: finalBookmarksToImport.length,
+ };
+ });
+ return { status: "fulfilled" as const, value: created };
+ } catch (e) {
+ setImportProgress((prev) => {
+ const newDone = (prev?.done ?? 0) + 1;
+ return {
+ done: newDone,
+ total: finalBookmarksToImport.length,
+ };
+ });
+ return { status: "rejected" as const };
+ }
+ },
);
const CONCURRENCY_LIMIT = 20;
@@ -268,7 +333,7 @@ export function ImportExportRow() {
});
}
- router.push(`/dashboard/lists/${importList.id}`);
+ router.push(`/dashboard/lists/${rootList.id}`);
},
onError: (error) => {
setImportProgress(null); // Clear progress on initial parsing error
diff --git a/apps/web/components/settings/UserOptions.tsx b/apps/web/components/settings/UserOptions.tsx
index 33ffc46a..3918ceed 100644
--- a/apps/web/components/settings/UserOptions.tsx
+++ b/apps/web/components/settings/UserOptions.tsx
@@ -1,11 +1,23 @@
"use client";
+import { useEffect } from "react";
+import { useClientConfig } from "@/lib/clientConfig";
import { useTranslation } from "@/lib/i18n/client";
import { useInterfaceLang } from "@/lib/userLocalSettings/bookmarksLayout";
import { updateInterfaceLang } from "@/lib/userLocalSettings/userLocalSettings";
+import { useUserSettings } from "@/lib/userSettings";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { useUpdateUserSettings } from "@karakeep/shared-react/hooks/users";
import { langNameMappings } from "@karakeep/shared/langs";
+import {
+ ZUserSettings,
+ zUserSettingsSchema,
+} from "@karakeep/shared/types/users";
+import { Form, FormField } from "../ui/form";
import { Label } from "../ui/label";
import {
Select,
@@ -14,6 +26,7 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
+import { toast } from "../ui/use-toast";
const LanguageSelect = () => {
const lang = useInterfaceLang();
@@ -38,6 +51,132 @@ const LanguageSelect = () => {
);
};
+export default function UserSettings() {
+ const { t } = useTranslation();
+ const clientConfig = useClientConfig();
+ const data = useUserSettings();
+ const { mutate } = useUpdateUserSettings({
+ onSuccess: () => {
+ toast({
+ description: t("settings.info.user_settings.user_settings_updated"),
+ });
+ },
+ onError: () => {
+ toast({
+ description: t("common.something_went_wrong"),
+ variant: "destructive",
+ });
+ },
+ });
+
+ const bookmarkClickActionTranslation: Record<
+ ZUserSettings["bookmarkClickAction"],
+ string
+ > = {
+ open_original_link: t(
+ "settings.info.user_settings.bookmark_click_action.open_external_url",
+ ),
+ expand_bookmark_preview: t(
+ "settings.info.user_settings.bookmark_click_action.open_bookmark_details",
+ ),
+ };
+
+ const archiveDisplayBehaviourTranslation: Record<
+ ZUserSettings["archiveDisplayBehaviour"],
+ string
+ > = {
+ show: t("settings.info.user_settings.archive_display_behaviour.show"),
+ hide: t("settings.info.user_settings.archive_display_behaviour.hide"),
+ };
+
+ const form = useForm<z.infer<typeof zUserSettingsSchema>>({
+ resolver: zodResolver(zUserSettingsSchema),
+ defaultValues: data,
+ });
+
+ // When the actual user setting is loaded, reset the form to the current value
+ useEffect(() => {
+ form.reset(data);
+ }, [data]);
+
+ return (
+ <Form {...form}>
+ <FormField
+ control={form.control}
+ name="bookmarkClickAction"
+ render={({ field }) => (
+ <div className="flex w-full flex-col gap-2">
+ <Label>
+ {t("settings.info.user_settings.bookmark_click_action.title")}
+ </Label>
+ <Select
+ disabled={!!clientConfig.demoMode}
+ value={field.value}
+ onValueChange={(value) => {
+ mutate({
+ bookmarkClickAction:
+ value as ZUserSettings["bookmarkClickAction"],
+ });
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue>
+ {bookmarkClickActionTranslation[field.value]}
+ </SelectValue>
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(bookmarkClickActionTranslation).map(
+ ([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ),
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="archiveDisplayBehaviour"
+ render={({ field }) => (
+ <div className="flex w-full flex-col gap-2">
+ <Label>
+ {t("settings.info.user_settings.archive_display_behaviour.title")}
+ </Label>
+ <Select
+ disabled={!!clientConfig.demoMode}
+ value={field.value}
+ onValueChange={(value) => {
+ mutate({
+ archiveDisplayBehaviour:
+ value as ZUserSettings["archiveDisplayBehaviour"],
+ });
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue>
+ {archiveDisplayBehaviourTranslation[field.value]}
+ </SelectValue>
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(archiveDisplayBehaviourTranslation).map(
+ ([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ),
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ )}
+ />
+ </Form>
+ );
+}
+
export function UserOptions() {
const { t } = useTranslation();
@@ -46,9 +185,12 @@ export function UserOptions() {
<div className="mb-4 w-full text-lg font-medium sm:w-1/3">
{t("settings.info.options")}
</div>
- <div className="flex w-full flex-col gap-2">
- <Label>{t("settings.info.interface_lang")}</Label>
- <LanguageSelect />
+ <div className="flex w-full flex-col gap-3">
+ <div className="flex w-full flex-col gap-2">
+ <Label>{t("settings.info.interface_lang")}</Label>
+ <LanguageSelect />
+ </div>
+ <UserSettings />
</div>
</div>
);