aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-06-07 15:11:29 +0000
committerMohamed Bassem <me@mbassem.com>2025-06-07 15:11:29 +0000
commit090c0d1c3c1b6bf2f569eb4c9e1164523f048319 (patch)
tree9d505df2ec4b029e384ca50b6296ec9ab03ebc58 /apps
parent39feafe797a7a1e0e54233a18fefa1eee82f9f95 (diff)
downloadkarakeep-090c0d1c3c1b6bf2f569eb4c9e1164523f048319.tar.zst
feat(web): Redesign the user settings page
Diffstat (limited to 'apps')
-rw-r--r--apps/web/app/settings/info/page.tsx4
-rw-r--r--apps/web/components/settings/ChangePassword.tsx225
-rw-r--r--apps/web/components/settings/UserDetails.tsx71
-rw-r--r--apps/web/components/settings/UserOptions.tsx192
4 files changed, 302 insertions, 190 deletions
diff --git a/apps/web/app/settings/info/page.tsx b/apps/web/app/settings/info/page.tsx
index c7d8f808..52a6ce9d 100644
--- a/apps/web/app/settings/info/page.tsx
+++ b/apps/web/app/settings/info/page.tsx
@@ -1,10 +1,10 @@
import { ChangePassword } from "@/components/settings/ChangePassword";
import UserDetails from "@/components/settings/UserDetails";
-import { UserOptions } from "@/components/settings/UserOptions";
+import UserOptions from "@/components/settings/UserOptions";
export default async function InfoPage() {
return (
- <div className="flex flex-col gap-8 rounded-md border bg-background p-4">
+ <div className="flex flex-col gap-4">
<UserDetails />
<ChangePassword />
<UserOptions />
diff --git a/apps/web/components/settings/ChangePassword.tsx b/apps/web/components/settings/ChangePassword.tsx
index f8c2b8dd..703b9c16 100644
--- a/apps/web/components/settings/ChangePassword.tsx
+++ b/apps/web/components/settings/ChangePassword.tsx
@@ -1,6 +1,7 @@
"use client";
import type { z } from "zod";
+import { useState } from "react";
import { ActionButton } from "@/components/ui/action-button";
import {
Form,
@@ -15,12 +16,19 @@ import { toast } from "@/components/ui/use-toast";
import { useTranslation } from "@/lib/i18n/client";
import { api } from "@/lib/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
+import { Eye, EyeOff, Lock } from "lucide-react";
import { useForm } from "react-hook-form";
import { zChangePasswordSchema } from "@karakeep/shared/types/users";
+import { Button } from "../ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
+
export function ChangePassword() {
const { t } = useTranslation();
+ const [showCurrentPassword, setShowCurrentPassword] = useState(false);
+ const [showNewPassword, setShowNewPassword] = useState(false);
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const form = useForm<z.infer<typeof zChangePasswordSchema>>({
resolver: zodResolver(zChangePasswordSchema),
defaultValues: {
@@ -55,83 +63,150 @@ export function ChangePassword() {
}
return (
- <div className="flex flex-col sm:flex-row">
- <div className="mb-4 w-full text-lg font-medium sm:w-1/3">
- {t("settings.info.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>{t("settings.info.current_password")}</FormLabel>
- <FormControl>
- <Input
- type="password"
- placeholder={t("settings.info.current_password")}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- );
- }}
- />
- <FormField
- control={form.control}
- name="newPassword"
- render={({ field }) => {
- return (
- <FormItem className="flex-1">
- <FormLabel>{t("settings.info.new_password")}</FormLabel>
- <FormControl>
- <Input
- type="password"
- placeholder={t("settings.info.new_password")}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- );
- }}
- />
- <FormField
- control={form.control}
- name="newPasswordConfirm"
- render={({ field }) => {
- return (
- <FormItem className="flex-1">
- <FormLabel>
- {t("settings.info.confirm_new_password")}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Lock className="h-5 w-5" />
+ Security
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="currentPassword"
+ render={({ field }) => (
+ <FormItem className="space-y-2">
+ <FormLabel
+ htmlFor="current-password"
+ className="text-sm font-medium"
+ >
+ {t("settings.info.current_password")}
</FormLabel>
- <FormControl>
- <Input
- type="Password"
- placeholder={t("settings.info.confirm_new_password")}
- {...field}
- />
- </FormControl>
+ <div className="relative">
+ <FormControl>
+ <Input
+ id="current-password"
+ type={showCurrentPassword ? "text" : "password"}
+ placeholder={t("settings.info.current_password")}
+ className="h-11 pr-10"
+ {...field}
+ />
+ </FormControl>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
+ onClick={() =>
+ setShowCurrentPassword(!showCurrentPassword)
+ }
+ >
+ {showCurrentPassword ? (
+ <EyeOff className="h-4 w-4" />
+ ) : (
+ <Eye className="h-4 w-4" />
+ )}
+ </Button>
+ </div>
<FormMessage />
</FormItem>
- );
- }}
- />
- <ActionButton
- className="mt-4 h-10 w-max px-8"
- type="submit"
- loading={mutator.isPending}
- >
- {t("actions.save")}
- </ActionButton>
- </form>
- </Form>
- </div>
+ )}
+ />
+
+ <div className="grid gap-4 md:grid-cols-2">
+ <FormField
+ control={form.control}
+ name="newPassword"
+ render={({ field }) => (
+ <FormItem className="space-y-2">
+ <FormLabel
+ htmlFor="new-password"
+ className="text-sm font-medium"
+ >
+ {t("settings.info.new_password")}
+ </FormLabel>
+ <div className="relative">
+ <FormControl>
+ <Input
+ id="new-password"
+ type={showNewPassword ? "text" : "password"}
+ placeholder={t("settings.info.new_password")}
+ className="h-11 pr-10"
+ {...field}
+ />
+ </FormControl>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
+ onClick={() => setShowNewPassword(!showNewPassword)}
+ >
+ {showNewPassword ? (
+ <EyeOff className="h-4 w-4" />
+ ) : (
+ <Eye className="h-4 w-4" />
+ )}
+ </Button>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="newPasswordConfirm"
+ render={({ field }) => (
+ <FormItem className="space-y-2">
+ <FormLabel
+ htmlFor="confirm-password"
+ className="text-sm font-medium"
+ >
+ {t("settings.info.confirm_new_password")}
+ </FormLabel>
+ <div className="relative">
+ <FormControl>
+ <Input
+ id="confirm-password"
+ type={showConfirmPassword ? "text" : "password"}
+ placeholder={t("settings.info.confirm_new_password")}
+ className="h-11 pr-10"
+ {...field}
+ />
+ </FormControl>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
+ onClick={() =>
+ setShowConfirmPassword(!showConfirmPassword)
+ }
+ >
+ {showConfirmPassword ? (
+ <EyeOff className="h-4 w-4" />
+ ) : (
+ <Eye className="h-4 w-4" />
+ )}
+ </Button>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="flex justify-end">
+ <ActionButton type="submit" loading={mutator.isPending}>
+ {t("actions.save")}
+ </ActionButton>
+ </div>
+ </form>
+ </Form>
+ </CardContent>
+ </Card>
);
}
diff --git a/apps/web/components/settings/UserDetails.tsx b/apps/web/components/settings/UserDetails.tsx
index af6698ad..6135df47 100644
--- a/apps/web/components/settings/UserDetails.tsx
+++ b/apps/web/components/settings/UserDetails.tsx
@@ -1,35 +1,60 @@
import { Input } from "@/components/ui/input";
import { useTranslation } from "@/lib/i18n/server";
import { api } from "@/server/api/client";
+import { Mail, User } from "lucide-react";
+
+import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
+import { Label } from "../ui/label";
export default async function UserDetails() {
const { t } = await useTranslation();
const whoami = await api.users.whoami();
- const details = [
- {
- label: t("common.name"),
- value: whoami.name ?? undefined,
- },
- {
- label: t("common.email"),
- value: whoami.email ?? undefined,
- },
- ];
-
return (
- <div className="flex w-full flex-col sm:flex-row">
- <div className="mb-4 w-full text-lg font-medium sm:w-1/3">
- {t("settings.info.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 />
+ <Card>
+ <CardHeader>
+ <div className="flex items-center space-x-4">
+ <div className="space-y-1">
+ <CardTitle className="flex items-center gap-2">
+ <User className="h-5 w-5" />
+ {t("settings.info.basic_details")}
+ </CardTitle>
+ </div>
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid gap-6 md:grid-cols-2">
+ <div className="space-y-2">
+ <Label htmlFor="name" className="text-sm font-medium">
+ {t("common.name")}
+ </Label>
+ <Input
+ id="name"
+ defaultValue={whoami.name ?? ""}
+ className="h-11"
+ disabled
+ />
+ </div>
+ <div className="space-y-2">
+ <Label
+ htmlFor="email"
+ className="flex items-center gap-2 text-sm font-medium"
+ >
+ <Mail className="h-4 w-4" />
+ {t("common.email")}
+ </Label>
+ <div className="relative">
+ <Input
+ id="email"
+ type="email"
+ defaultValue={whoami.email ?? ""}
+ className="h-11"
+ disabled
+ />
+ </div>
</div>
- ))}
- </div>
- </div>
+ </div>
+ </CardContent>
+ </Card>
);
}
diff --git a/apps/web/components/settings/UserOptions.tsx b/apps/web/components/settings/UserOptions.tsx
index 3918ceed..483c3f2b 100644
--- a/apps/web/components/settings/UserOptions.tsx
+++ b/apps/web/components/settings/UserOptions.tsx
@@ -7,6 +7,7 @@ import { useInterfaceLang } from "@/lib/userLocalSettings/bookmarksLayout";
import { updateInterfaceLang } from "@/lib/userLocalSettings/userLocalSettings";
import { useUserSettings } from "@/lib/userSettings";
import { zodResolver } from "@hookform/resolvers/zod";
+import { Archive, Bookmark, Globe } from "lucide-react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -17,6 +18,7 @@ import {
zUserSettingsSchema,
} from "@karakeep/shared/types/users";
+import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { Form, FormField } from "../ui/form";
import { Label } from "../ui/label";
import {
@@ -26,6 +28,7 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
+import { Separator } from "../ui/separator";
import { toast } from "../ui/use-toast";
const LanguageSelect = () => {
@@ -37,7 +40,7 @@ const LanguageSelect = () => {
await updateInterfaceLang(val);
}}
>
- <SelectTrigger>
+ <SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -51,7 +54,7 @@ const LanguageSelect = () => {
);
};
-export default function UserSettings() {
+export default function UserOptions() {
const { t } = useTranslation();
const clientConfig = useClientConfig();
const data = useUserSettings();
@@ -101,97 +104,106 @@ export default function UserSettings() {
return (
<Form {...form}>
- <FormField
- control={form.control}
- name="bookmarkClickAction"
- render={({ field }) => (
- <div className="flex w-full flex-col gap-2">
- <Label>
- {t("settings.info.user_settings.bookmark_click_action.title")}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Globe className="h-5 w-5" />
+ {t("settings.info.options")}
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="space-y-2">
+ <Label className="text-sm font-medium">
+ {t("settings.info.interface_lang")}
</Label>
- <Select
- disabled={!!clientConfig.demoMode}
- value={field.value}
- onValueChange={(value) => {
- mutate({
- bookmarkClickAction:
- value as ZUserSettings["bookmarkClickAction"],
- });
- }}
- >
- <SelectTrigger>
- <SelectValue>
- {bookmarkClickActionTranslation[field.value]}
- </SelectValue>
- </SelectTrigger>
- <SelectContent>
- {Object.entries(bookmarkClickActionTranslation).map(
- ([value, label]) => (
- <SelectItem key={value} value={value}>
- {label}
- </SelectItem>
- ),
- )}
- </SelectContent>
- </Select>
+ <LanguageSelect />
</div>
- )}
- />
- <FormField
- control={form.control}
- name="archiveDisplayBehaviour"
- render={({ field }) => (
- <div className="flex w-full flex-col gap-2">
- <Label>
- {t("settings.info.user_settings.archive_display_behaviour.title")}
- </Label>
- <Select
- disabled={!!clientConfig.demoMode}
- value={field.value}
- onValueChange={(value) => {
- mutate({
- archiveDisplayBehaviour:
- value as ZUserSettings["archiveDisplayBehaviour"],
- });
- }}
- >
- <SelectTrigger>
- <SelectValue>
- {archiveDisplayBehaviourTranslation[field.value]}
- </SelectValue>
- </SelectTrigger>
- <SelectContent>
- {Object.entries(archiveDisplayBehaviourTranslation).map(
- ([value, label]) => (
- <SelectItem key={value} value={value}>
- {label}
- </SelectItem>
- ),
- )}
- </SelectContent>
- </Select>
- </div>
- )}
- />
- </Form>
- );
-}
-export function UserOptions() {
- const { t } = useTranslation();
+ <Separator />
- return (
- <div className="flex flex-col sm:flex-row">
- <div className="mb-4 w-full text-lg font-medium sm:w-1/3">
- {t("settings.info.options")}
- </div>
- <div className="flex w-full flex-col gap-3">
- <div className="flex w-full flex-col gap-2">
- <Label>{t("settings.info.interface_lang")}</Label>
- <LanguageSelect />
- </div>
- <UserSettings />
- </div>
- </div>
+ <div className="grid gap-6 md:grid-cols-2">
+ <FormField
+ control={form.control}
+ name="bookmarkClickAction"
+ render={({ field }) => (
+ <div className="space-y-2">
+ <Label className="flex items-center gap-2 text-sm font-medium">
+ <Bookmark className="h-4 w-4" />
+ {t(
+ "settings.info.user_settings.bookmark_click_action.title",
+ )}
+ </Label>
+ <Select
+ disabled={!!clientConfig.demoMode}
+ value={field.value}
+ onValueChange={(value) => {
+ mutate({
+ bookmarkClickAction:
+ value as ZUserSettings["bookmarkClickAction"],
+ });
+ }}
+ >
+ <SelectTrigger className="h-11">
+ <SelectValue>
+ {bookmarkClickActionTranslation[field.value]}
+ </SelectValue>
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(bookmarkClickActionTranslation).map(
+ ([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ),
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="archiveDisplayBehaviour"
+ render={({ field }) => (
+ <div className="space-y-2">
+ <Label className="flex items-center gap-2 text-sm font-medium">
+ <Archive className="h-4 w-4" />
+ {t(
+ "settings.info.user_settings.archive_display_behaviour.title",
+ )}
+ </Label>
+ <Select
+ disabled={!!clientConfig.demoMode}
+ value={field.value}
+ onValueChange={(value) => {
+ mutate({
+ archiveDisplayBehaviour:
+ value as ZUserSettings["archiveDisplayBehaviour"],
+ });
+ }}
+ >
+ <SelectTrigger className="h-11">
+ <SelectValue>
+ {archiveDisplayBehaviourTranslation[field.value]}
+ </SelectValue>
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(archiveDisplayBehaviourTranslation).map(
+ ([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ),
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+ </Form>
);
}