diff options
Diffstat (limited to 'apps/web/components/settings')
| -rw-r--r-- | apps/web/components/settings/FeedSettings.tsx | 72 | ||||
| -rw-r--r-- | apps/web/components/settings/ImportExport.tsx | 155 | ||||
| -rw-r--r-- | apps/web/components/settings/UserOptions.tsx | 148 |
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> ); |
