From eb7da996a7c2d617d276f296cac07a6fd5648664 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 27 Oct 2024 12:03:14 +0000 Subject: ui: Redesign the settings page and move it to its own layout --- apps/web/components/settings/AISettings.tsx | 326 +++++++++++++++++++++ apps/web/components/settings/AddApiKey.tsx | 157 ++++++++++ apps/web/components/settings/ApiKeySettings.tsx | 49 ++++ apps/web/components/settings/ChangePassword.tsx | 133 +++++++++ apps/web/components/settings/DeleteApiKey.tsx | 55 ++++ apps/web/components/settings/ImportExport.tsx | 263 +++++++++++++++++ apps/web/components/settings/UserDetails.tsx | 33 +++ .../components/settings/sidebar/ModileSidebar.tsx | 19 ++ apps/web/components/settings/sidebar/Sidebar.tsx | 34 +++ apps/web/components/settings/sidebar/items.tsx | 34 +++ 10 files changed, 1103 insertions(+) create mode 100644 apps/web/components/settings/AISettings.tsx create mode 100644 apps/web/components/settings/AddApiKey.tsx create mode 100644 apps/web/components/settings/ApiKeySettings.tsx create mode 100644 apps/web/components/settings/ChangePassword.tsx create mode 100644 apps/web/components/settings/DeleteApiKey.tsx create mode 100644 apps/web/components/settings/ImportExport.tsx create mode 100644 apps/web/components/settings/UserDetails.tsx create mode 100644 apps/web/components/settings/sidebar/ModileSidebar.tsx create mode 100644 apps/web/components/settings/sidebar/Sidebar.tsx create mode 100644 apps/web/components/settings/sidebar/items.tsx (limited to 'apps/web/components/settings') diff --git a/apps/web/components/settings/AISettings.tsx b/apps/web/components/settings/AISettings.tsx new file mode 100644 index 00000000..0a8db147 --- /dev/null +++ b/apps/web/components/settings/AISettings.tsx @@ -0,0 +1,326 @@ +"use client"; + +import { ActionButton } from "@/components/ui/action-button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; +import { FullPageSpinner } from "@/components/ui/full-page-spinner"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "@/components/ui/use-toast"; +import { useClientConfig } from "@/lib/clientConfig"; +import { api } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Plus, Save, Trash2 } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { buildImagePrompt, buildTextPrompt } from "@hoarder/shared/prompts"; +import { + zNewPromptSchema, + ZPrompt, + zUpdatePromptSchema, +} from "@hoarder/shared/types/prompts"; + +export function PromptEditor() { + const apiUtils = api.useUtils(); + + const form = useForm>({ + resolver: zodResolver(zNewPromptSchema), + defaultValues: { + text: "", + appliesTo: "all", + }, + }); + + const { mutateAsync: createPrompt, isPending: isCreating } = + api.prompts.create.useMutation({ + onSuccess: () => { + toast({ + description: "Prompt has been created!", + }); + apiUtils.prompts.list.invalidate(); + }, + }); + + return ( +
+ { + await createPrompt(value); + form.resetField("text"); + })} + > + { + return ( + + + + + + + ); + }} + /> + + { + return ( + + + + + + + ); + }} + /> + + + Add + + + + ); +} + +export function PromptRow({ prompt }: { prompt: ZPrompt }) { + const apiUtils = api.useUtils(); + const { mutateAsync: updatePrompt, isPending: isUpdating } = + api.prompts.update.useMutation({ + onSuccess: () => { + toast({ + description: "Prompt has been updated!", + }); + apiUtils.prompts.list.invalidate(); + }, + }); + const { mutate: deletePrompt, isPending: isDeleting } = + api.prompts.delete.useMutation({ + onSuccess: () => { + toast({ + description: "Prompt has been deleted!", + }); + apiUtils.prompts.list.invalidate(); + }, + }); + + const form = useForm>({ + resolver: zodResolver(zUpdatePromptSchema), + defaultValues: { + promptId: prompt.id, + text: prompt.text, + appliesTo: prompt.appliesTo, + }, + }); + + return ( +
+ { + await updatePrompt(value); + })} + > + { + return ( + + + + + + + ); + }} + /> + { + return ( + + + + + + + ); + }} + /> + + { + return ( + + + + + + + ); + }} + /> + + + Save + + deletePrompt({ promptId: prompt.id })} + className="items-center" + type="button" + > + + Delete + + + + ); +} + +export function TaggingRules() { + const { data: prompts, isLoading } = api.prompts.list.useQuery(); + + return ( +
+
Tagging Rules
+

+ Prompts that you add here will be included as rules to the model during + tag generation. You can view the final prompts in the prompt preview + section. +

+ {isLoading && } + {prompts && prompts.length == 0 && ( +

+ You don't have any custom prompts yet. +

+ )} + {prompts && + prompts.map((prompt) => )} + +
+ ); +} + +export function PromptDemo() { + const { data: prompts } = api.prompts.list.useQuery(); + const clientConfig = useClientConfig(); + return ( +
+
+ Prompt Preview +
+

Text Prompt

+ + {buildTextPrompt( + clientConfig.inference.inferredTagLang, + (prompts ?? []) + .filter((p) => p.appliesTo == "text" || p.appliesTo == "all") + .map((p) => p.text), + "\n\n", + /* context length */ 1024 /* The value here doesn't matter */, + ).trim()} + +

Image Prompt

+ + {buildImagePrompt( + clientConfig.inference.inferredTagLang, + (prompts ?? []) + .filter((p) => p.appliesTo == "images" || p.appliesTo == "all") + .map((p) => p.text), + ).trim()} + +
+ ); +} + +export default function AISettings() { + return ( + <> +
+
+
+ AI Settings +
+ +
+
+
+ +
+ + ); +} diff --git a/apps/web/components/settings/AddApiKey.tsx b/apps/web/components/settings/AddApiKey.tsx new file mode 100644 index 00000000..34fd2df7 --- /dev/null +++ b/apps/web/components/settings/AddApiKey.tsx @@ -0,0 +1,157 @@ +"use client"; + +import type { SubmitErrorHandler } from "react-hook-form"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import CopyBtn from "@/components/ui/copy-button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +function ApiKeySuccess({ apiKey }: { apiKey: string }) { + return ( +
+
+ Note: please copy the key and store it somewhere safe. Once you close + the dialog, you won't be able to access it again. +
+
+ + { + return apiKey; + }} + /> +
+
+ ); +} + +function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) { + 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: "Something went wrong", variant: "destructive" }); + }, + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + }); + + async function onSubmit(value: z.infer) { + mutator.mutate({ name: value.name }); + } + + const onError: SubmitErrorHandler> = (errors) => { + toast({ + description: Object.values(errors) + .map((v) => v.message) + .join("\n"), + variant: "destructive", + }); + }; + + return ( +
+ + { + return ( + + Name + + + + + Give your API key a unique name + + + + ); + }} + /> + + Create + + + + ); +} + +export default function AddApiKey() { + const [key, setKey] = useState(undefined); + const [dialogOpen, setDialogOpen] = useState(false); + return ( + + + + + + + + {key ? "Key was successfully created" : "Create API key"} + + + {key ? ( + + ) : ( + + )} + + + + + + + + + + ); +} diff --git a/apps/web/components/settings/ApiKeySettings.tsx b/apps/web/components/settings/ApiKeySettings.tsx new file mode 100644 index 00000000..4d43be7a --- /dev/null +++ b/apps/web/components/settings/ApiKeySettings.tsx @@ -0,0 +1,49 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/server/api/client"; + +import AddApiKey from "./AddApiKey"; +import DeleteApiKey from "./DeleteApiKey"; + +export default async function ApiKeys() { + const keys = await api.apiKeys.list(); + return ( +
+
+
API Keys
+ +
+
+ + + + Name + Key + Created At + Action + + + + {keys.keys.map((k) => ( + + {k.name} + **_{k.keyId}_** + {k.createdAt.toLocaleString()} + + + + + ))} + + +
+
+
+ ); +} diff --git a/apps/web/components/settings/ChangePassword.tsx b/apps/web/components/settings/ChangePassword.tsx new file mode 100644 index 00000000..aa27f223 --- /dev/null +++ b/apps/web/components/settings/ChangePassword.tsx @@ -0,0 +1,133 @@ +"use client"; + +import type { z } from "zod"; +import { ActionButton } from "@/components/ui/action-button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; + +import { zChangePasswordSchema } from "@hoarder/shared/types/users"; + +export function ChangePassword() { + const form = useForm>({ + resolver: zodResolver(zChangePasswordSchema), + defaultValues: { + currentPassword: "", + newPassword: "", + newPasswordConfirm: "", + }, + }); + + 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" }); + } + }, + }); + + async function onSubmit(value: z.infer) { + mutator.mutate({ + currentPassword: value.currentPassword, + newPassword: value.newPassword, + }); + } + + return ( +
+
+ Change Password +
+
+ + { + return ( + + Current Password + + + + + + ); + }} + /> + { + return ( + + New Password + + + + + + ); + }} + /> + { + return ( + + Confirm New Password + + + + + + ); + }} + /> + + Save + + + +
+ ); +} diff --git a/apps/web/components/settings/DeleteApiKey.tsx b/apps/web/components/settings/DeleteApiKey.tsx new file mode 100644 index 00000000..e2334c44 --- /dev/null +++ b/apps/web/components/settings/DeleteApiKey.tsx @@ -0,0 +1,55 @@ +"use client"; + +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 { api } from "@/lib/trpc"; +import { Trash } from "lucide-react"; + +export default function DeleteApiKey({ + name, + id, +}: { + name: string; + id: string; +}) { + const router = useRouter(); + const mutator = api.apiKeys.revoke.useMutation({ + onSuccess: () => { + toast({ + description: "Key was successfully deleted", + }); + router.refresh(); + }, + }); + + return ( + + Are you sure you want to delete the API key "{name}"? Any + service using this API key will lose access. +

+ } + actionButton={(setDialogOpen) => ( + + mutator.mutate({ id }, { onSuccess: () => setDialogOpen(false) }) + } + > + Delete + + )} + > + +
+ ); +} diff --git a/apps/web/components/settings/ImportExport.tsx b/apps/web/components/settings/ImportExport.tsx new file mode 100644 index 00000000..1145a42d --- /dev/null +++ b/apps/web/components/settings/ImportExport.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +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 { toast } from "@/components/ui/use-toast"; +import { + ParsedBookmark, + parseHoarderBookmarkFile, + parseNetscapeBookmarkFile, + parsePocketBookmarkFile, +} 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 { + useCreateBookmarkWithPostHook, + useUpdateBookmark, + useUpdateBookmarkTags, +} from "@hoarder/shared-react/hooks/bookmarks"; +import { + useAddBookmarkToList, + useCreateBookmarkList, +} from "@hoarder/shared-react/hooks/lists"; +import { BookmarkTypes } from "@hoarder/shared/types/bookmarks"; + +export function ExportButton() { + return ( + + +

Export Links and Notes

+ + ); +} + +export function ImportExportRow() { + const router = useRouter(); + + const [importProgress, setImportProgress] = useState<{ + done: number; + total: number; + } | null>(null); + + const { mutateAsync: createBookmark } = useCreateBookmarkWithPostHook(); + const { mutateAsync: updateBookmark } = useUpdateBookmark(); + const { mutateAsync: createList } = useCreateBookmarkList(); + const { mutateAsync: addToList } = useAddBookmarkToList(); + const { mutateAsync: updateTags } = useUpdateBookmarkTags(); + + const { mutateAsync: parseAndCreateBookmark } = useMutation({ + mutationFn: async (toImport: { + bookmark: ParsedBookmark; + listId: string; + }) => { + const bookmark = toImport.bookmark; + if (bookmark.content === undefined) { + throw new Error("Content is undefined"); + } + const created = await createBookmark( + bookmark.content.type === BookmarkTypes.LINK + ? { + type: BookmarkTypes.LINK, + url: bookmark.content.url, + } + : { + type: BookmarkTypes.TEXT, + text: bookmark.content.text, + }, + ); + + await Promise.all([ + // Update title and createdAt if they're set + bookmark.title.length > 0 || bookmark.addDate + ? updateBookmark({ + bookmarkId: created.id, + title: bookmark.title, + createdAt: bookmark.addDate + ? new Date(bookmark.addDate * 1000) + : undefined, + note: bookmark.notes, + }).catch(() => { + /* empty */ + }) + : undefined, + + // 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; + } + }), + + // Update tags + bookmark.tags.length > 0 + ? updateTags({ + bookmarkId: created.id, + attach: bookmark.tags.map((t) => ({ tagName: t })), + detach: [], + }) + : undefined, + ]); + return created; + }, + }); + + const { mutateAsync: runUploadBookmarkFile } = useMutation({ + mutationFn: async ({ + file, + source, + }: { + file: File; + source: "html" | "pocket" | "hoarder"; + }) => { + if (source === "html") { + return await parseNetscapeBookmarkFile(file); + } else if (source === "pocket") { + return await parsePocketBookmarkFile(file); + } else if (source === "hoarder") { + return await parseHoarderBookmarkFile(file); + } else { + throw new Error("Unknown source"); + } + }, + onSuccess: async (resp) => { + const importList = await createList({ + name: `Imported Bookmarks`, + icon: "⬆️", + }); + setImportProgress({ done: 0, total: resp.length }); + + const successes = []; + const failed = []; + const alreadyExisted = []; + // Do the imports one by one + for (const parsedBookmark of resp) { + try { + const result = await parseAndCreateBookmark({ + bookmark: parsedBookmark, + listId: importList.id, + }); + if (result.alreadyExists) { + alreadyExisted.push(parsedBookmark); + } else { + successes.push(parsedBookmark); + } + } catch (e) { + failed.push(parsedBookmark); + } + setImportProgress((prev) => ({ + done: (prev?.done ?? 0) + 1, + total: resp.length, + })); + } + + if (successes.length > 0 || alreadyExisted.length > 0) { + toast({ + description: `Imported ${successes.length} bookmarks and skipped ${alreadyExisted.length} bookmarks that already existed`, + variant: "default", + }); + } + + if (failed.length > 0) { + toast({ + description: `Failed to import ${failed.length} bookmarks`, + variant: "destructive", + }); + } + + router.push(`/dashboard/lists/${importList.id}`); + }, + onError: (error) => { + toast({ + description: error.message, + variant: "destructive", + }); + }, + }); + + return ( +
+
+ + runUploadBookmarkFile({ file, source: "html" }) + } + > + +

Import Bookmarks from HTML file

+
+ + + runUploadBookmarkFile({ file, source: "pocket" }) + } + > + +

Import Bookmarks from Pocket export

+
+ + runUploadBookmarkFile({ file, source: "hoarder" }) + } + > + +

Import Bookmarks from Hoarder export

+
+ +
+ {importProgress && ( +
+

+ Processed {importProgress.done} of {importProgress.total} bookmarks +

+
+ +
+
+ )} +
+ ); +} + +export default function ImportExport() { + return ( +
+

Import / Export Bookmarks

+ +
+ ); +} diff --git a/apps/web/components/settings/UserDetails.tsx b/apps/web/components/settings/UserDetails.tsx new file mode 100644 index 00000000..471a6e09 --- /dev/null +++ b/apps/web/components/settings/UserDetails.tsx @@ -0,0 +1,33 @@ +import { Input } from "@/components/ui/input"; +import { api } from "@/server/api/client"; + +export default async function UserDetails() { + const whoami = await api.users.whoami(); + + const details = [ + { + label: "Name", + value: whoami.name ?? undefined, + }, + { + label: "Email", + value: whoami.email ?? undefined, + }, + ]; + + return ( +
+
+ Basic Details +
+
+ {details.map(({ label, value }) => ( +
+
{label}
+ +
+ ))} +
+
+ ); +} diff --git a/apps/web/components/settings/sidebar/ModileSidebar.tsx b/apps/web/components/settings/sidebar/ModileSidebar.tsx new file mode 100644 index 00000000..2016c931 --- /dev/null +++ b/apps/web/components/settings/sidebar/ModileSidebar.tsx @@ -0,0 +1,19 @@ +import MobileSidebarItem from "@/components/shared/sidebar/ModileSidebarItem"; + +import { settingsSidebarItems } from "./items"; + +export default async function MobileSidebar() { + return ( + + ); +} diff --git a/apps/web/components/settings/sidebar/Sidebar.tsx b/apps/web/components/settings/sidebar/Sidebar.tsx new file mode 100644 index 00000000..247e0916 --- /dev/null +++ b/apps/web/components/settings/sidebar/Sidebar.tsx @@ -0,0 +1,34 @@ +import { redirect } from "next/navigation"; +import SidebarItem from "@/components/shared/sidebar/SidebarItem"; +import { getServerAuthSession } from "@/server/auth"; + +import serverConfig from "@hoarder/shared/config"; + +import { settingsSidebarItems } from "./items"; + +export default async function Sidebar() { + const session = await getServerAuthSession(); + if (!session) { + redirect("/"); + } + + return ( + + ); +} diff --git a/apps/web/components/settings/sidebar/items.tsx b/apps/web/components/settings/sidebar/items.tsx new file mode 100644 index 00000000..999825db --- /dev/null +++ b/apps/web/components/settings/sidebar/items.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { ArrowLeft, Download, KeyRound, Sparkles, User } from "lucide-react"; + +export const settingsSidebarItems: { + name: string; + icon: JSX.Element; + path: string; +}[] = [ + { + name: "Back To App", + icon: , + path: "/dashboard/bookmarks", + }, + { + name: "User Info", + icon: , + path: "/settings/info", + }, + { + name: "AI Settings", + icon: , + path: "/settings/ai", + }, + { + name: "Import / Export", + icon: , + path: "/settings/import", + }, + { + name: "API Keys", + icon: , + path: "/settings/api-keys", + }, +]; -- cgit v1.2.3-70-g09d2