diff options
Diffstat (limited to 'apps/web/components/settings')
| -rw-r--r-- | apps/web/components/settings/AISettings.tsx | 326 | ||||
| -rw-r--r-- | apps/web/components/settings/AddApiKey.tsx | 157 | ||||
| -rw-r--r-- | apps/web/components/settings/ApiKeySettings.tsx | 49 | ||||
| -rw-r--r-- | apps/web/components/settings/ChangePassword.tsx | 133 | ||||
| -rw-r--r-- | apps/web/components/settings/DeleteApiKey.tsx | 55 | ||||
| -rw-r--r-- | apps/web/components/settings/ImportExport.tsx | 263 | ||||
| -rw-r--r-- | apps/web/components/settings/UserDetails.tsx | 33 | ||||
| -rw-r--r-- | apps/web/components/settings/sidebar/ModileSidebar.tsx | 19 | ||||
| -rw-r--r-- | apps/web/components/settings/sidebar/Sidebar.tsx | 34 | ||||
| -rw-r--r-- | apps/web/components/settings/sidebar/items.tsx | 34 |
10 files changed, 1103 insertions, 0 deletions
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<z.infer<typeof zNewPromptSchema>>({ + 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 ( + <Form {...form}> + <form + className="flex gap-2" + onSubmit={form.handleSubmit(async (value) => { + await createPrompt(value); + form.resetField("text"); + })} + > + <FormField + control={form.control} + name="text" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormControl> + <Input + placeholder="Add a custom prompt" + type="text" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + + <FormField + control={form.control} + name="appliesTo" + render={({ field }) => { + return ( + <FormItem className="flex-0"> + <FormControl> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <SelectTrigger> + <SelectValue placeholder="Applies To" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="all">All</SelectItem> + <SelectItem value="text">Text</SelectItem> + <SelectItem value="images">Images</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <ActionButton + type="submit" + loading={isCreating} + variant="default" + className="items-center" + > + <Plus className="mr-2 size-4" /> + Add + </ActionButton> + </form> + </Form> + ); +} + +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<z.infer<typeof zUpdatePromptSchema>>({ + resolver: zodResolver(zUpdatePromptSchema), + defaultValues: { + promptId: prompt.id, + text: prompt.text, + appliesTo: prompt.appliesTo, + }, + }); + + return ( + <Form {...form}> + <form + className="flex gap-2" + onSubmit={form.handleSubmit(async (value) => { + await updatePrompt(value); + })} + > + <FormField + control={form.control} + name="promptId" + render={({ field }) => { + return ( + <FormItem className="hidden"> + <FormControl> + <Input + placeholder="Add a custom prompt" + type="hidden" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="text" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormControl> + <Input + placeholder="Add a custom prompt" + type="text" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + + <FormField + control={form.control} + name="appliesTo" + render={({ field }) => { + return ( + <FormItem className="flex-0"> + <FormControl> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <SelectTrigger> + <SelectValue placeholder="Applies To" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="all">All</SelectItem> + <SelectItem value="text">Text</SelectItem> + <SelectItem value="images">Images</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <ActionButton + loading={isUpdating} + variant="secondary" + type="submit" + className="items-center" + > + <Save className="mr-2 size-4" /> + Save + </ActionButton> + <ActionButton + loading={isDeleting} + variant="destructive" + onClick={() => deletePrompt({ promptId: prompt.id })} + className="items-center" + type="button" + > + <Trash2 className="mr-2 size-4" /> + Delete + </ActionButton> + </form> + </Form> + ); +} + +export function TaggingRules() { + const { data: prompts, isLoading } = api.prompts.list.useQuery(); + + return ( + <div className="mt-2 flex flex-col gap-2"> + <div className="w-full text-xl font-medium sm:w-1/3">Tagging Rules</div> + <p className="mb-1 text-xs italic text-muted-foreground"> + 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. + </p> + {isLoading && <FullPageSpinner />} + {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> + )} + {prompts && + prompts.map((prompt) => <PromptRow key={prompt.id} prompt={prompt} />)} + <PromptEditor /> + </div> + ); +} + +export function PromptDemo() { + const { data: prompts } = api.prompts.list.useQuery(); + const clientConfig = useClientConfig(); + return ( + <div className="flex flex-col gap-2"> + <div className="mb-4 w-full text-xl font-medium sm:w-1/3"> + Prompt Preview + </div> + <p>Text Prompt</p> + <code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> + {buildTextPrompt( + clientConfig.inference.inferredTagLang, + (prompts ?? []) + .filter((p) => p.appliesTo == "text" || p.appliesTo == "all") + .map((p) => p.text), + "\n<CONTENT_HERE>\n", + /* context length */ 1024 /* The value here doesn't matter */, + ).trim()} + </code> + <p>Image 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") + .map((p) => p.text), + ).trim()} + </code> + </div> + ); +} + +export default function AISettings() { + 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"> + AI Settings + </div> + <TaggingRules /> + </div> + </div> + <div className="mt-4 rounded-md border bg-background p-4"> + <PromptDemo /> + </div> + </> + ); +} 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 ( + <div> + <div className="py-4"> + Note: please copy the key and store it somewhere safe. Once you close + the dialog, you won't be able to access it again. + </div> + <div className="flex space-x-2 pt-2"> + <Input value={apiKey} readOnly /> + <CopyBtn + getStringToCopy={() => { + return apiKey; + }} + /> + </div> + </div> + ); +} + +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<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + }); + + async function onSubmit(value: z.infer<typeof formSchema>) { + mutator.mutate({ name: value.name }); + } + + const onError: SubmitErrorHandler<z.infer<typeof formSchema>> = (errors) => { + toast({ + description: Object.values(errors) + .map((v) => v.message) + .join("\n"), + variant: "destructive", + }); + }; + + return ( + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit, onError)} + className="flex w-full space-x-3 space-y-8 pt-4" + > + <FormField + control={form.control} + name="name" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormLabel>Name</FormLabel> + <FormControl> + <Input type="text" placeholder="Name" {...field} /> + </FormControl> + <FormDescription> + Give your API key a unique name + </FormDescription> + <FormMessage /> + </FormItem> + ); + }} + /> + <ActionButton + className="h-full" + type="submit" + loading={mutator.isPending} + > + Create + </ActionButton> + </form> + </Form> + ); +} + +export default function AddApiKey() { + const [key, setKey] = useState<string | undefined>(undefined); + const [dialogOpen, setDialogOpen] = useState<boolean>(false); + return ( + <Dialog open={dialogOpen} onOpenChange={setDialogOpen}> + <DialogTrigger asChild> + <Button>New API Key</Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle> + {key ? "Key was successfully created" : "Create API key"} + </DialogTitle> + <DialogDescription> + {key ? ( + <ApiKeySuccess apiKey={key} /> + ) : ( + <AddApiKeyForm onSuccess={setKey} /> + )} + </DialogDescription> + </DialogHeader> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button + type="button" + variant="outline" + onClick={() => setKey(undefined)} + > + Close + </Button> + </DialogClose> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} 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 ( + <div> + <div className="flex items-center justify-between"> + <div className="mb-2 text-lg font-medium">API Keys</div> + <AddApiKey /> + </div> + <div className="mt-2"> + <Table> + <TableHeader> + <TableRow> + <TableHead>Name</TableHead> + <TableHead>Key</TableHead> + <TableHead>Created At</TableHead> + <TableHead>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> + <DeleteApiKey name={k.name} id={k.id} /> + </TableCell> + </TableRow> + ))} + <TableRow></TableRow> + </TableBody> + </Table> + </div> + </div> + ); +} 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<z.infer<typeof zChangePasswordSchema>>({ + 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<typeof zChangePasswordSchema>) { + mutator.mutate({ + currentPassword: value.currentPassword, + newPassword: value.newPassword, + }); + } + + return ( + <div className="flex flex-col sm:flex-row"> + <div className="mb-4 w-full text-lg font-medium sm:w-1/3"> + Change Password + </div> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex w-full flex-col gap-2" + > + <FormField + control={form.control} + name="currentPassword" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormLabel>Current Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Current Password" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="newPassword" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormLabel>New Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="New Password" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="newPasswordConfirm" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormLabel>Confirm New Password</FormLabel> + <FormControl> + <Input + type="Password" + placeholder="Confirm New Password" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <ActionButton + className="mt-4 h-10 w-max px-8" + type="submit" + loading={mutator.isPending} + > + Save + </ActionButton> + </form> + </Form> + </div> + ); +} 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 ( + <ActionConfirmingDialog + title={"Delete API Key"} + description={ + <p> + Are you sure you want to delete the API key "{name}"? Any + service using this API key will lose access. + </p> + } + actionButton={(setDialogOpen) => ( + <ActionButton + type="button" + variant="destructive" + loading={mutator.isPending} + onClick={() => + mutator.mutate({ id }, { onSuccess: () => setDialogOpen(false) }) + } + > + Delete + </ActionButton> + )} + > + <Button variant="outline"> + <Trash size={18} color="red" /> + </Button> + </ActionConfirmingDialog> + ); +} 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 ( + <Link + href="/api/bookmarks/export" + className={cn( + buttonVariants({ variant: "default" }), + "flex items-center gap-2", + )} + > + <Download /> + <p>Export Links and Notes</p> + </Link> + ); +} + +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 ( + <div className="flex flex-col gap-3"> + <div className="flex flex-row flex-wrap gap-2"> + <FilePickerButton + loading={false} + accept=".html" + multiple={false} + className="flex items-center gap-2" + onFileSelect={(file) => + runUploadBookmarkFile({ file, source: "html" }) + } + > + <Upload /> + <p>Import Bookmarks from HTML file</p> + </FilePickerButton> + + <FilePickerButton + loading={false} + accept=".html" + multiple={false} + className="flex items-center gap-2" + onFileSelect={(file) => + runUploadBookmarkFile({ file, source: "pocket" }) + } + > + <Upload /> + <p>Import Bookmarks from Pocket export</p> + </FilePickerButton> + <FilePickerButton + loading={false} + accept=".json" + multiple={false} + className="flex items-center gap-2" + onFileSelect={(file) => + runUploadBookmarkFile({ file, source: "hoarder" }) + } + > + <Upload /> + <p>Import Bookmarks from Hoarder export</p> + </FilePickerButton> + <ExportButton /> + </div> + {importProgress && ( + <div className="flex flex-col gap-2"> + <p className="shrink-0 text-sm"> + Processed {importProgress.done} of {importProgress.total} bookmarks + </p> + <div className="w-full"> + <Progress + value={(importProgress.done * 100) / importProgress.total} + /> + </div> + </div> + )} + </div> + ); +} + +export default function ImportExport() { + return ( + <div className="flex w-full flex-col gap-2"> + <p className="mb-4 text-lg font-medium">Import / Export Bookmarks</p> + <ImportExportRow /> + </div> + ); +} 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 ( + <div className="mb-8 flex w-full flex-col sm:flex-row"> + <div className="mb-4 w-full text-lg font-medium sm:w-1/3"> + Basic Details + </div> + <div className="w-full"> + {details.map(({ label, value }) => ( + <div className="mb-2" key={label}> + <div className="mb-2 text-sm font-medium">{label}</div> + <Input value={value} disabled /> + </div> + ))} + </div> + </div> + ); +} 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 ( + <aside className="w-full"> + <ul className="flex justify-between space-x-2 border-b-black px-5 py-2 pt-5"> + {settingsSidebarItems.map((item) => ( + <MobileSidebarItem + key={item.name} + logo={item.icon} + path={item.path} + /> + ))} + </ul> + </aside> + ); +} 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 ( + <aside className="flex h-[calc(100vh-64px)] w-60 flex-col gap-5 border-r p-4 "> + <div> + <ul className="space-y-2 text-sm font-medium"> + {settingsSidebarItems.map((item) => ( + <SidebarItem + key={item.name} + logo={item.icon} + name={item.name} + path={item.path} + /> + ))} + </ul> + </div> + <div className="mt-auto flex items-center border-t pt-2 text-sm text-gray-400"> + Hoarder v{serverConfig.serverVersion} + </div> + </aside> + ); +} 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: <ArrowLeft size={18} />, + path: "/dashboard/bookmarks", + }, + { + name: "User Info", + icon: <User size={18} />, + path: "/settings/info", + }, + { + name: "AI Settings", + icon: <Sparkles size={18} />, + path: "/settings/ai", + }, + { + name: "Import / Export", + icon: <Download size={18} />, + path: "/settings/import", + }, + { + name: "API Keys", + icon: <KeyRound size={18} />, + path: "/settings/api-keys", + }, +]; |
