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/AISettings.tsx326
-rw-r--r--apps/web/components/settings/AddApiKey.tsx157
-rw-r--r--apps/web/components/settings/ApiKeySettings.tsx49
-rw-r--r--apps/web/components/settings/ChangePassword.tsx133
-rw-r--r--apps/web/components/settings/DeleteApiKey.tsx55
-rw-r--r--apps/web/components/settings/ImportExport.tsx263
-rw-r--r--apps/web/components/settings/UserDetails.tsx33
-rw-r--r--apps/web/components/settings/sidebar/ModileSidebar.tsx19
-rw-r--r--apps/web/components/settings/sidebar/Sidebar.tsx34
-rw-r--r--apps/web/components/settings/sidebar/items.tsx34
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&apos;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&apos;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 &quot;{name}&quot;? 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",
+ },
+];