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/app/dashboard/settings/page.tsx | 60 ----
apps/web/app/settings/ai/page.tsx | 5 +
apps/web/app/settings/api-keys/page.tsx | 9 +
apps/web/app/settings/import/page.tsx | 9 +
apps/web/app/settings/info/page.tsx | 11 +
apps/web/app/settings/layout.tsx | 34 +++
apps/web/app/settings/page.tsx | 6 +
.../components/dashboard/header/ProfileOptions.tsx | 2 +-
.../components/dashboard/settings/AISettings.tsx | 326 ---------------------
.../components/dashboard/settings/AddApiKey.tsx | 157 ----------
.../dashboard/settings/ApiKeySettings.tsx | 49 ----
.../dashboard/settings/ChangePassword.tsx | 133 ---------
.../components/dashboard/settings/DeleteApiKey.tsx | 55 ----
.../components/dashboard/settings/ImportExport.tsx | 263 -----------------
.../components/dashboard/settings/UserDetails.tsx | 33 ---
apps/web/components/dashboard/sidebar/AllLists.tsx | 2 +-
.../components/dashboard/sidebar/ModileSidebar.tsx | 3 +-
.../dashboard/sidebar/ModileSidebarItem.tsx | 27 --
apps/web/components/dashboard/sidebar/Sidebar.tsx | 2 +-
.../components/dashboard/sidebar/SidebarItem.tsx | 55 ----
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 +++
.../shared/sidebar/ModileSidebarItem.tsx | 27 ++
apps/web/components/shared/sidebar/SidebarItem.tsx | 55 ++++
32 files changed, 1263 insertions(+), 1163 deletions(-)
delete mode 100644 apps/web/app/dashboard/settings/page.tsx
create mode 100644 apps/web/app/settings/ai/page.tsx
create mode 100644 apps/web/app/settings/api-keys/page.tsx
create mode 100644 apps/web/app/settings/import/page.tsx
create mode 100644 apps/web/app/settings/info/page.tsx
create mode 100644 apps/web/app/settings/layout.tsx
create mode 100644 apps/web/app/settings/page.tsx
delete mode 100644 apps/web/components/dashboard/settings/AISettings.tsx
delete mode 100644 apps/web/components/dashboard/settings/AddApiKey.tsx
delete mode 100644 apps/web/components/dashboard/settings/ApiKeySettings.tsx
delete mode 100644 apps/web/components/dashboard/settings/ChangePassword.tsx
delete mode 100644 apps/web/components/dashboard/settings/DeleteApiKey.tsx
delete mode 100644 apps/web/components/dashboard/settings/ImportExport.tsx
delete mode 100644 apps/web/components/dashboard/settings/UserDetails.tsx
delete mode 100644 apps/web/components/dashboard/sidebar/ModileSidebarItem.tsx
delete mode 100644 apps/web/components/dashboard/sidebar/SidebarItem.tsx
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
create mode 100644 apps/web/components/shared/sidebar/ModileSidebarItem.tsx
create mode 100644 apps/web/components/shared/sidebar/SidebarItem.tsx
(limited to 'apps/web')
diff --git a/apps/web/app/dashboard/settings/page.tsx b/apps/web/app/dashboard/settings/page.tsx
deleted file mode 100644
index 11883d55..00000000
--- a/apps/web/app/dashboard/settings/page.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import AISettings from "@/components/dashboard/settings/AISettings";
-import ApiKeySettings from "@/components/dashboard/settings/ApiKeySettings";
-import { ChangePassword } from "@/components/dashboard/settings/ChangePassword";
-import ImportExport from "@/components/dashboard/settings/ImportExport";
-import UserDetails from "@/components/dashboard/settings/UserDetails";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { Download, KeyRound, Sparkle, User } from "lucide-react";
-
-export default async function Settings() {
- return (
-
-
-
-
- User Info
-
-
-
- AI Settings
-
-
-
- Import / Export
-
-
-
- API Keys
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/app/settings/ai/page.tsx b/apps/web/app/settings/ai/page.tsx
new file mode 100644
index 00000000..2b3d7a8d
--- /dev/null
+++ b/apps/web/app/settings/ai/page.tsx
@@ -0,0 +1,5 @@
+import AISettings from "@/components/settings/AISettings";
+
+export default function AISettingsPage() {
+ return ;
+}
diff --git a/apps/web/app/settings/api-keys/page.tsx b/apps/web/app/settings/api-keys/page.tsx
new file mode 100644
index 00000000..1c3718d6
--- /dev/null
+++ b/apps/web/app/settings/api-keys/page.tsx
@@ -0,0 +1,9 @@
+import ApiKeySettings from "@/components/settings/ApiKeySettings";
+
+export default async function ApiKeysPage() {
+ return (
+
+ );
+}
diff --git a/apps/web/app/settings/import/page.tsx b/apps/web/app/settings/import/page.tsx
new file mode 100644
index 00000000..e27aa9a8
--- /dev/null
+++ b/apps/web/app/settings/import/page.tsx
@@ -0,0 +1,9 @@
+import ImportExport from "@/components/settings/ImportExport";
+
+export default function ImportSettingsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/settings/info/page.tsx b/apps/web/app/settings/info/page.tsx
new file mode 100644
index 00000000..8027b09f
--- /dev/null
+++ b/apps/web/app/settings/info/page.tsx
@@ -0,0 +1,11 @@
+import { ChangePassword } from "@/components/settings/ChangePassword";
+import UserDetails from "@/components/settings/UserDetails";
+
+export default async function InfoPage() {
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx
new file mode 100644
index 00000000..0ab6c624
--- /dev/null
+++ b/apps/web/app/settings/layout.tsx
@@ -0,0 +1,34 @@
+import Header from "@/components/dashboard/header/Header";
+import DemoModeBanner from "@/components/DemoModeBanner";
+import MobileSidebar from "@/components/settings/sidebar/ModileSidebar";
+import Sidebar from "@/components/settings/sidebar/Sidebar";
+import { Separator } from "@/components/ui/separator";
+import ValidAccountCheck from "@/components/utils/ValidAccountCheck";
+
+import serverConfig from "@hoarder/shared/config";
+
+export default async function SettingsLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+
+
+
+
+
+
+ {serverConfig.demoMode && }
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/apps/web/app/settings/page.tsx b/apps/web/app/settings/page.tsx
new file mode 100644
index 00000000..de935c84
--- /dev/null
+++ b/apps/web/app/settings/page.tsx
@@ -0,0 +1,6 @@
+import { redirect } from "next/navigation";
+
+export default function SettingsHomepage() {
+ redirect("/settings/info");
+ return null;
+}
diff --git a/apps/web/components/dashboard/header/ProfileOptions.tsx b/apps/web/components/dashboard/header/ProfileOptions.tsx
index ea8c7d12..ee6cac01 100644
--- a/apps/web/components/dashboard/header/ProfileOptions.tsx
+++ b/apps/web/components/dashboard/header/ProfileOptions.tsx
@@ -62,7 +62,7 @@ export default function SidebarProfileOptions() {
-
+
User Settings
diff --git a/apps/web/components/dashboard/settings/AISettings.tsx b/apps/web/components/dashboard/settings/AISettings.tsx
deleted file mode 100644
index 0a8db147..00000000
--- a/apps/web/components/dashboard/settings/AISettings.tsx
+++ /dev/null
@@ -1,326 +0,0 @@
-"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/dashboard/settings/AddApiKey.tsx b/apps/web/components/dashboard/settings/AddApiKey.tsx
deleted file mode 100644
index 34fd2df7..00000000
--- a/apps/web/components/dashboard/settings/AddApiKey.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-"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/dashboard/settings/ApiKeySettings.tsx b/apps/web/components/dashboard/settings/ApiKeySettings.tsx
deleted file mode 100644
index 4d43be7a..00000000
--- a/apps/web/components/dashboard/settings/ApiKeySettings.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-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/dashboard/settings/ChangePassword.tsx b/apps/web/components/dashboard/settings/ChangePassword.tsx
deleted file mode 100644
index aa27f223..00000000
--- a/apps/web/components/dashboard/settings/ChangePassword.tsx
+++ /dev/null
@@ -1,133 +0,0 @@
-"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/dashboard/settings/DeleteApiKey.tsx b/apps/web/components/dashboard/settings/DeleteApiKey.tsx
deleted file mode 100644
index e2334c44..00000000
--- a/apps/web/components/dashboard/settings/DeleteApiKey.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-"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/dashboard/settings/ImportExport.tsx b/apps/web/components/dashboard/settings/ImportExport.tsx
deleted file mode 100644
index 1145a42d..00000000
--- a/apps/web/components/dashboard/settings/ImportExport.tsx
+++ /dev/null
@@ -1,263 +0,0 @@
-"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/dashboard/settings/UserDetails.tsx b/apps/web/components/dashboard/settings/UserDetails.tsx
deleted file mode 100644
index 471a6e09..00000000
--- a/apps/web/components/dashboard/settings/UserDetails.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-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/dashboard/sidebar/AllLists.tsx b/apps/web/components/dashboard/sidebar/AllLists.tsx
index b6cadea9..c48ddb0f 100644
--- a/apps/web/components/dashboard/sidebar/AllLists.tsx
+++ b/apps/web/components/dashboard/sidebar/AllLists.tsx
@@ -3,6 +3,7 @@
import { useCallback } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
+import SidebarItem from "@/components/shared/sidebar/SidebarItem";
import { Button } from "@/components/ui/button";
import { CollapsibleTriggerTriangle } from "@/components/ui/collapsible";
import { MoreHorizontal, Plus } from "lucide-react";
@@ -13,7 +14,6 @@ import { ZBookmarkListTreeNode } from "@hoarder/shared/utils/listUtils";
import { CollapsibleBookmarkLists } from "../lists/CollapsibleBookmarkLists";
import { EditListModal } from "../lists/EditListModal";
import { ListOptions } from "../lists/ListOptions";
-import SidebarItem from "./SidebarItem";
export default function AllLists({
initialData,
diff --git a/apps/web/components/dashboard/sidebar/ModileSidebar.tsx b/apps/web/components/dashboard/sidebar/ModileSidebar.tsx
index 7ccf6b8d..bfa91afa 100644
--- a/apps/web/components/dashboard/sidebar/ModileSidebar.tsx
+++ b/apps/web/components/dashboard/sidebar/ModileSidebar.tsx
@@ -1,8 +1,7 @@
+import MobileSidebarItem from "@/components/shared/sidebar/ModileSidebarItem";
import HoarderLogoIcon from "@/public/icons/logo-icon.svg";
import { ClipboardList, Search, Tag } from "lucide-react";
-import MobileSidebarItem from "./ModileSidebarItem";
-
export default async function MobileSidebar() {
return (
diff --git a/apps/web/components/dashboard/sidebar/ModileSidebarItem.tsx b/apps/web/components/dashboard/sidebar/ModileSidebarItem.tsx
deleted file mode 100644
index 4d3436ea..00000000
--- a/apps/web/components/dashboard/sidebar/ModileSidebarItem.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { cn } from "@/lib/utils";
-
-export default function MobileSidebarItem({
- logo,
- path,
-}: {
- logo: React.ReactNode;
- path: string;
-}) {
- const currentPath = usePathname();
- return (
-
-
- {logo}
-
-
- );
-}
diff --git a/apps/web/components/dashboard/sidebar/Sidebar.tsx b/apps/web/components/dashboard/sidebar/Sidebar.tsx
index 14d019ff..8021ad36 100644
--- a/apps/web/components/dashboard/sidebar/Sidebar.tsx
+++ b/apps/web/components/dashboard/sidebar/Sidebar.tsx
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
+import SidebarItem from "@/components/shared/sidebar/SidebarItem";
import { Separator } from "@/components/ui/separator";
import { api } from "@/server/api/client";
import { getServerAuthSession } from "@/server/auth";
@@ -7,7 +8,6 @@ import { Archive, Home, Search, Tag } from "lucide-react";
import serverConfig from "@hoarder/shared/config";
import AllLists from "./AllLists";
-import SidebarItem from "./SidebarItem";
export default async function Sidebar() {
const session = await getServerAuthSession();
diff --git a/apps/web/components/dashboard/sidebar/SidebarItem.tsx b/apps/web/components/dashboard/sidebar/SidebarItem.tsx
deleted file mode 100644
index 83ce776e..00000000
--- a/apps/web/components/dashboard/sidebar/SidebarItem.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-"use client";
-
-import React from "react";
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { cn } from "@/lib/utils";
-
-export default function SidebarItem({
- name,
- logo,
- path,
- className,
- linkClassName,
- style,
- collapseButton,
- right = null,
-}: {
- name: string;
- logo: React.ReactNode;
- path: string;
- style?: React.CSSProperties;
- className?: string;
- linkClassName?: string;
- right?: React.ReactNode;
- collapseButton?: React.ReactNode;
-}) {
- const currentPath = usePathname();
- return (
-
- {collapseButton}
-
-
-
- {logo}
- {name}
-
- {right}
-
-
-
- );
-}
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",
+ },
+];
diff --git a/apps/web/components/shared/sidebar/ModileSidebarItem.tsx b/apps/web/components/shared/sidebar/ModileSidebarItem.tsx
new file mode 100644
index 00000000..4d3436ea
--- /dev/null
+++ b/apps/web/components/shared/sidebar/ModileSidebarItem.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
+
+export default function MobileSidebarItem({
+ logo,
+ path,
+}: {
+ logo: React.ReactNode;
+ path: string;
+}) {
+ const currentPath = usePathname();
+ return (
+
+
+ {logo}
+
+
+ );
+}
diff --git a/apps/web/components/shared/sidebar/SidebarItem.tsx b/apps/web/components/shared/sidebar/SidebarItem.tsx
new file mode 100644
index 00000000..83ce776e
--- /dev/null
+++ b/apps/web/components/shared/sidebar/SidebarItem.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import React from "react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
+
+export default function SidebarItem({
+ name,
+ logo,
+ path,
+ className,
+ linkClassName,
+ style,
+ collapseButton,
+ right = null,
+}: {
+ name: string;
+ logo: React.ReactNode;
+ path: string;
+ style?: React.CSSProperties;
+ className?: string;
+ linkClassName?: string;
+ right?: React.ReactNode;
+ collapseButton?: React.ReactNode;
+}) {
+ const currentPath = usePathname();
+ return (
+
+ {collapseButton}
+
+
+
+ {logo}
+ {name}
+
+ {right}
+
+
+
+ );
+}
--
cgit v1.2.3-70-g09d2