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 (
+
+
+ );
+}
+
+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 (
+
+
+ );
+}
+
+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 (
+ <>
+
+
+ >
+ );
+}
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 (
+
+
+ );
+}
+
+export default function AddApiKey() {
+ const [key, setKey] = useState(undefined);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ return (
+
+
+ New API Key
+
+
+
+
+ {key ? "Key was successfully created" : "Create API key"}
+
+
+ {key ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ setKey(undefined)}
+ >
+ Close
+
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+
+ 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
+
+
+
+
+ );
+}
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 }) => (
+
+ ))}
+
+
+ );
+}
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 (
+
+
+ {settingsSidebarItems.map((item) => (
+
+ ))}
+
+
+ );
+}
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