public inbox for [email protected]
 help / color / mirror / Atom feed
From: Muhammad Rizki <[email protected]>
To: Ammar Faizi <[email protected]>
Cc: Muhammad Rizki <[email protected]>,
	Alviro Iskandar Setiawan <[email protected]>,
	GNU/Weeb Mailing List <[email protected]>
Subject: [PATCH v1 13/13] feat: add settings pages
Date: Sun, 23 Feb 2025 05:54:19 +0700	[thread overview]
Message-ID: <[email protected]> (raw)
In-Reply-To: <[email protected]>

This commit adds profile management page and account management page,
including its schemas and local UI components.

Signed-off-by: Muhammad Rizki <[email protected]>
---
 src/lib/schemas/account-schema.ts             |  12 ++
 src/lib/schemas/profile-schema.ts             |   8 +
 .../(components)/settings-header.svelte       |  13 ++
 .../settings/(components)/settings-nav.svelte |  45 +++++
 .../(protected)/settings/+layout.svelte       |  28 +++
 src/routes/(protected)/settings/+page.ts      |  11 ++
 .../(protected)/settings/account/+page.svelte | 107 +++++++++++
 .../(protected)/settings/account/+page.ts     |   9 +
 .../(protected)/settings/profile/+page.svelte | 170 ++++++++++++++++++
 .../(protected)/settings/profile/+page.ts     |  20 +++
 10 files changed, 423 insertions(+)
 create mode 100644 src/lib/schemas/account-schema.ts
 create mode 100644 src/lib/schemas/profile-schema.ts
 create mode 100644 src/routes/(protected)/settings/(components)/settings-header.svelte
 create mode 100644 src/routes/(protected)/settings/(components)/settings-nav.svelte
 create mode 100644 src/routes/(protected)/settings/+layout.svelte
 create mode 100644 src/routes/(protected)/settings/+page.ts
 create mode 100644 src/routes/(protected)/settings/account/+page.svelte
 create mode 100644 src/routes/(protected)/settings/account/+page.ts
 create mode 100644 src/routes/(protected)/settings/profile/+page.svelte
 create mode 100644 src/routes/(protected)/settings/profile/+page.ts

diff --git a/src/lib/schemas/account-schema.ts b/src/lib/schemas/account-schema.ts
new file mode 100644
index 0000000..d633405
--- /dev/null
+++ b/src/lib/schemas/account-schema.ts
@@ -0,0 +1,12 @@
+import { z } from "zod";
+
+export const accountSchema = z
+  .object({
+    cur_pass: z.string(),
+    new_pass: z.string(),
+    retype_new_pass: z.string()
+  })
+  .refine(({ new_pass, retype_new_pass }) => new_pass === retype_new_pass, {
+    message: "The new password does not match with the retyped one.",
+    path: ["retype_new_pass"]
+  });
diff --git a/src/lib/schemas/profile-schema.ts b/src/lib/schemas/profile-schema.ts
new file mode 100644
index 0000000..fa67c17
--- /dev/null
+++ b/src/lib/schemas/profile-schema.ts
@@ -0,0 +1,8 @@
+import { z } from "zod";
+
+export const profileSchema = z.object({
+  avatar: z.instanceof(File).optional(),
+  username: z.string().optional(),
+  full_name: z.string().optional(),
+  gender: z.string().optional()
+});
diff --git a/src/routes/(protected)/settings/(components)/settings-header.svelte b/src/routes/(protected)/settings/(components)/settings-header.svelte
new file mode 100644
index 0000000..cc13950
--- /dev/null
+++ b/src/routes/(protected)/settings/(components)/settings-header.svelte
@@ -0,0 +1,13 @@
+<script lang="ts">
+  import { page } from "$app/state";
+  import { settingsNav } from "$constants/navigations";
+  import Separator from "$components/ui/separator/separator.svelte";
+
+  const activeNav = $derived(settingsNav.find((e) => page.url.pathname === e.url));
+</script>
+
+<div>
+  <h3 class="font-medium">{activeNav?.name}</h3>
+  <p class="text-sm text-muted-foreground">{activeNav?.description}</p>
+</div>
+<Separator />
diff --git a/src/routes/(protected)/settings/(components)/settings-nav.svelte b/src/routes/(protected)/settings/(components)/settings-nav.svelte
new file mode 100644
index 0000000..1ff5968
--- /dev/null
+++ b/src/routes/(protected)/settings/(components)/settings-nav.svelte
@@ -0,0 +1,45 @@
+<script lang="ts">
+  import { cubicInOut } from "svelte/easing";
+  import { crossfade } from "svelte/transition";
+  import { cn } from "$utils";
+  import { page } from "$app/state";
+  import type { HTMLAttributes } from "svelte/elements";
+  import Button from "$components/ui/button/button.svelte";
+  import * as typing from "$typings";
+
+  interface Props extends HTMLAttributes<HTMLDivElement> {
+    items: typing.Navigations[];
+  }
+
+  let { items, class: className }: Props = $props();
+
+  const [send, receive] = crossfade({
+    duration: 250,
+    easing: cubicInOut
+  });
+</script>
+
+<nav class={cn("flex space-x-2 xl:flex-col xl:space-x-0 xl:space-y-1", className)}>
+  {#each items as item}
+    {@const isActive = page.url.pathname === item.url}
+    <Button
+      href={item.url}
+      variant="ghost"
+      class={cn(!isActive && "hover:underline", "relative justify-start hover:bg-transparent")}
+      disabled={item.disabled}
+      data-sveltekit-noscroll
+    >
+      {#if isActive}
+        <!-- svelte-ignore element_invalid_self_closing_tag -->
+        <div
+          class="absolute inset-0 rounded-md bg-muted"
+          in:send={{ key: "active-sidebar-tab" }}
+          out:receive={{ key: "active-sidebar-tab" }}
+        />
+      {/if}
+      <div class="relative">
+        {item.name}
+      </div>
+    </Button>
+  {/each}
+</nav>
diff --git a/src/routes/(protected)/settings/+layout.svelte b/src/routes/(protected)/settings/+layout.svelte
new file mode 100644
index 0000000..3ef02e4
--- /dev/null
+++ b/src/routes/(protected)/settings/+layout.svelte
@@ -0,0 +1,28 @@
+<script lang="ts">
+  import { Separator } from "$lib/components/ui/separator";
+
+  import { settingsNav } from "$constants/navigations";
+  import SettingsNavigation from "./(components)/settings-nav.svelte";
+  import SettingsHeader from "./(components)/settings-header.svelte";
+
+  let { children } = $props();
+</script>
+
+<div class="space-y-6 pb-16 prose-h2:text-2xl prose-h3:text-lg">
+  <div class="space-y-0.5">
+    <h2 class="font-bold tracking-tight">Settings</h2>
+    <p class="text-muted-foreground">Manage your account settings and set e-mail preferences.</p>
+  </div>
+  <Separator class="my-6" />
+  <div class="flex flex-col space-y-8 xl:flex-row xl:space-x-8 xl:space-y-0">
+    <aside class="xl:w-[12%]">
+      <SettingsNavigation items={settingsNav} />
+    </aside>
+    <div class="flex-1">
+      <div class="space-y-6">
+        <SettingsHeader />
+        {@render children()}
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/src/routes/(protected)/settings/+page.ts b/src/routes/(protected)/settings/+page.ts
new file mode 100644
index 0000000..3c846ad
--- /dev/null
+++ b/src/routes/(protected)/settings/+page.ts
@@ -0,0 +1,11 @@
+import { redirect } from "@sveltejs/kit";
+import type { PageLoad } from "./$types";
+import { settingsNav } from "$constants/navigations";
+
+export const load: PageLoad = async () => {
+  // get first page that are not disabled.
+  const firstPage = settingsNav.find((e) => !e.disabled);
+
+  // if it don't exist, redirect to index page.
+  return redirect(307, firstPage?.url ?? "/");
+};
diff --git a/src/routes/(protected)/settings/account/+page.svelte b/src/routes/(protected)/settings/account/+page.svelte
new file mode 100644
index 0000000..00590bf
--- /dev/null
+++ b/src/routes/(protected)/settings/account/+page.svelte
@@ -0,0 +1,107 @@
+<script lang="ts">
+  import { accountSchema } from "$lib/schemas/account-schema";
+  import { setError, superForm } from "sveltekit-superforms";
+  import { zodClient } from "sveltekit-superforms/adapters";
+  import { toast } from "svelte-sonner";
+  import * as Form from "$components/ui/form";
+  import InputPassword from "$components/ui/input/input-password.svelte";
+  import Button from "$components/ui/button/button.svelte";
+  import http from "$lib/hooks/http.svelte";
+
+  let { data } = $props();
+
+  const form = superForm(data.form, {
+    SPA: true,
+    validators: zodClient(accountSchema),
+    validationMethod: "oninput",
+
+    async onUpdate({ form }) {
+      const res = await http<string>({
+        params: { action: "change_password" },
+        method: "POST",
+        data: form.data
+      });
+
+      const message = res.data.res as string;
+
+      if (res.status >= 400) {
+        if (message.includes("current password is wrong")) {
+          setError(form, "cur_pass", message);
+        } else {
+          setError(form, "new_pass", message);
+          setError(form, "retype_new_pass", message);
+        }
+      } else {
+        reset();
+        toast.info(message);
+      }
+    }
+  });
+
+  const { form: formData, errors, submitting, constraints, enhance, reset } = form;
+
+  const isSubmittable = $derived(
+    Boolean($formData.cur_pass && $formData.new_pass && $formData.retype_new_pass)
+  );
+
+  const isError = $derived(
+    Boolean($errors.cur_pass || $errors.new_pass || $errors.retype_new_pass)
+  );
+</script>
+
+<form use:enhance class="space-y-5">
+  <Form.Field {form} name="cur_pass">
+    <Form.Control>
+      {#snippet children({ props })}
+        <Form.Label>Current Password</Form.Label>
+        <InputPassword
+          {...props}
+          aria-invalid={$errors.cur_pass ? "true" : undefined}
+          bind:value={$formData.cur_pass}
+          placeholder="Enter current password"
+          disabled={$submitting}
+          {...$constraints.cur_pass}
+        />
+        <Form.FieldErrors />
+      {/snippet}
+    </Form.Control>
+  </Form.Field>
+
+  <Form.Field {form} name="new_pass">
+    <Form.Control>
+      {#snippet children({ props })}
+        <Form.Label>New Password</Form.Label>
+        <InputPassword
+          {...props}
+          aria-invalid={$errors.new_pass ? "true" : undefined}
+          bind:value={$formData.new_pass}
+          placeholder="Enter new password"
+          disabled={$submitting}
+          {...$constraints.new_pass}
+        />
+        <Form.FieldErrors />
+      {/snippet}
+    </Form.Control>
+  </Form.Field>
+
+  <Form.Field {form} name="retype_new_pass">
+    <Form.Control>
+      {#snippet children({ props })}
+        <Form.Label>Re-type New Password</Form.Label>
+        <InputPassword
+          {...props}
+          aria-invalid={$errors.retype_new_pass ? "true" : undefined}
+          bind:value={$formData.retype_new_pass}
+          placeholder="Re-type new password"
+          disabled={$submitting}
+          {...$constraints.retype_new_pass}
+        />
+        <Form.FieldErrors />
+      {/snippet}
+    </Form.Control>
+  </Form.Field>
+
+  <Button type="submit" class="px-8" disabled={$submitting || !isSubmittable || isError}>
+    Submit
+  </Button>
+</form>
diff --git a/src/routes/(protected)/settings/account/+page.ts b/src/routes/(protected)/settings/account/+page.ts
new file mode 100644
index 0000000..8089d3e
--- /dev/null
+++ b/src/routes/(protected)/settings/account/+page.ts
@@ -0,0 +1,9 @@
+import type { PageLoad } from "./$types";
+import { zod } from "sveltekit-superforms/adapters";
+import { superValidate } from "sveltekit-superforms";
+import { accountSchema } from "$lib/schemas/account-schema";
+
+export const load: PageLoad = async () => {
+  const form = await superValidate(zod(accountSchema));
+  return { form };
+};
diff --git a/src/routes/(protected)/settings/profile/+page.svelte b/src/routes/(protected)/settings/profile/+page.svelte
new file mode 100644
index 0000000..442e271
--- /dev/null
+++ b/src/routes/(protected)/settings/profile/+page.svelte
@@ -0,0 +1,170 @@
+<script lang="ts">
+  import { useAuth } from "$lib/hooks/auth.svelte";
+  import { superForm } from "sveltekit-superforms";
+  import { zodClient } from "sveltekit-superforms/adapters";
+  import { profileSchema } from "$lib/schemas/profile-schema";
+  import { Pencil } from "lucide-svelte";
+  import * as Avatar from "$lib/components/ui/avatar";
+  import * as Form from "$lib/components/ui/form";
+  import * as RadioGroup from "$lib/components/ui/radio-group";
+  import Input from "$components/ui/input/input.svelte";
+  import Label from "$components/ui/label/label.svelte";
+
+  let { data } = $props();
+
+  const auth = useAuth();
+
+  let avatarImage = $state<string>();
+
+  const form = superForm(data.form, {
+    SPA: true,
+    validators: zodClient(profileSchema),
+  });
+
+  const getShortName = () => {
+    const fullName = auth.user?.full_name ?? "";
+    const match = fullName.match(/\b(\w)/g) ?? [];
+    return match.slice(0, 2).join("");
+  };
+
+  const handleAvatar = (e: any) => {
+    const file = e.srcElement.files[0];
+    const reader = new FileReader();
+    reader.readAsDataURL(file);
+
+    reader.onload = function () {
+      avatarImage = reader.result as string;
+    };
+  };
+
+  const { form: formData, errors, submitting, constraints, enhance } = form;
+</script>
+
+<form
+  use:enhance
+  class="flex w-full max-w-5xl flex-col gap-y-8 lg:flex-row lg:justify-between lg:gap-x-8 lg:gap-y-0"
+>
+  <div class="flex w-full justify-center lg:hidden">
+    <Form.Field {form} name="avatar">
+      <Form.Control>
+        {#snippet children({ props })}
+          <Form.Label for="avatar" class="relative cursor-pointer">
+            <Avatar.Root class="size-40">
+              <Avatar.Image src={avatarImage} alt="@{auth.user?.username}" />
+              <Avatar.Fallback class="text-xl">{getShortName()}</Avatar.Fallback>
+            </Avatar.Root>
+            <div
+              class="absolute bottom-3 left-0 flex items-center gap-x-1 rounded-lg bg-foreground px-2 py-1 text-primary-foreground"
+            >
+              <Pencil class="size-4" />
+              <span class="text-xs font-medium">Edit</span>
+            </div>
+          </Form.Label>
+          <Input
+            type="file"
+            accept="image/png,image/jpeg"
+            {...props}
+            aria-invalid={$errors.avatar ? "true" : undefined}
+            bind:value={$formData.avatar}
+            disabled={$submitting}
+            {...$constraints.avatar}
+            class="hidden"
+            onchange={handleAvatar}
+          />
+        {/snippet}
+      </Form.Control>
+    </Form.Field>
+  </div>
+
+  <div class="flex w-full max-w-3xl flex-col space-y-5">
+    <Form.Field {form} name="username">
+      <Form.Control>
+        {#snippet children({ props })}
+          <Form.Label>Username</Form.Label>
+          <Input
+            {...props}
+            aria-invalid={$errors.username ? "true" : undefined}
+            bind:value={$formData.username}
+            placeholder="@username"
+            {...$constraints.username}
+            disabled
+          />
+          <Form.Description>This is your GNU/Weeb email username.</Form.Description>
+        {/snippet}
+      </Form.Control>
+    </Form.Field>
+
+    <Form.Field {form} name="full_name">
+      <Form.Control>
+        {#snippet children({ props })}
+          <Form.Label>Full Name</Form.Label>
+          <Input
+            {...props}
+            aria-invalid={$errors.full_name ? "true" : undefined}
+            bind:value={$formData.full_name}
+            placeholder="Your full name"
+            {...$constraints.full_name}
+            disabled
+          />
+        {/snippet}
+      </Form.Control>
+    </Form.Field>
+
+    <Form.Field {form} name="gender">
+      <Form.Control>
+        {#snippet children({ props })}
+          <Form.Label>Gender</Form.Label>
+          <RadioGroup.Root
+            {...props}
+            aria-invalid={$errors.gender ? "true" : undefined}
+            bind:value={$formData.gender}
+            placeholder="Your full name"
+            {...$constraints.gender}
+            disabled
+          >
+            <div class="flex items-center space-x-2">
+              <RadioGroup.Item value="m" id="m" />
+              <Label for="m">Male</Label>
+            </div>
+            <div class="flex items-center space-x-2">
+              <RadioGroup.Item value="f" id="f" />
+              <Label for="f">Female</Label>
+            </div>
+          </RadioGroup.Root>
+        {/snippet}
+      </Form.Control>
+    </Form.Field>
+  </div>
+
+  <div class="hidden lg:block">
+    <Form.Field {form} name="avatar">
+      <Form.Control>
+        {#snippet children({ props })}
+          <Form.Label for="avatar" class="relative cursor-pointer">
+            <Avatar.Root class="lg:size-40 xl:size-52">
+              <Avatar.Image src={avatarImage} alt="@{auth.user?.username}" />
+              <Avatar.Fallback class="lg:text-xl xl:text-3xl">{getShortName()}</Avatar.Fallback>
+            </Avatar.Root>
+            <div
+              class="absolute bottom-3 left-0 flex items-center gap-x-1 rounded-lg bg-foreground px-2 py-1 text-primary-foreground xl:left-2.5"
+            >
+              <Pencil class="size-4" />
+              <span class="text-xs font-medium">Edit</span>
+            </div>
+          </Form.Label>
+          <Input
+            type="file"
+            accept="image/png,image/jpeg"
+            {...props}
+            aria-invalid={$errors.avatar ? "true" : undefined}
+            bind:value={$formData.avatar}
+            disabled={$submitting}
+            {...$constraints.avatar}
+            class="hidden"
+            onchange={handleAvatar}
+          />
+        {/snippet}
+      </Form.Control>
+    </Form.Field>
+  </div>
+</form>
diff --git a/src/routes/(protected)/settings/profile/+page.ts b/src/routes/(protected)/settings/profile/+page.ts
new file mode 100644
index 0000000..9e036c8
--- /dev/null
+++ b/src/routes/(protected)/settings/profile/+page.ts
@@ -0,0 +1,20 @@
+import type { PageLoad } from "./$types";
+import { zod } from "sveltekit-superforms/adapters";
+import { superValidate } from "sveltekit-superforms";
+import { profileSchema } from "$lib/schemas/profile-schema";
+import { useAuth } from "$lib/hooks/auth.svelte";
+
+export const load: PageLoad = async () => {
+  const auth = useAuth();
+  auth.refresh();
+
+  const data = {
+    username: auth.user?.username,
+    full_name: auth.user?.full_name,
+    gender: auth.user?.gender
+  };
+
+  const form = await superValidate(data, zod(profileSchema));
+
+  return { form };
+};
-- 
Muhammad Rizki


  parent reply	other threads:[~2025-02-22 22:55 UTC|newest]

Thread overview: 19+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-02-22 22:54 [PATCH v1 00/13] Add Profile & Account Management Muhammad Rizki
2025-02-22 22:54 ` [PATCH v1 01/13] fix(toaster): add Toaster component Muhammad Rizki
2025-02-22 22:54 ` [PATCH v1 02/13] chore(login): remove unnecessary default data Muhammad Rizki
2025-02-22 22:54 ` [PATCH v1 03/13] feat(constants): add settingsNav data Muhammad Rizki
2025-02-22 22:54 ` [PATCH v1 04/13] feat(typings/common): add disabled property for Navigations Muhammad Rizki
2025-02-22 22:54 ` [PATCH v1 05/13] refactor: update HTTP client, typings, and login method Muhammad Rizki
2025-02-22 22:54 ` [PATCH v1 06/13] chore(schema): rename login schema Muhammad Rizki
2025-02-22 22:54 ` [PATCH v1 07/13] feat(ui): add avatar ui Muhammad Rizki
2025-02-22 22:54 ` [PATCH v1 08/13] chore(sidebar-menu): add active menu style Muhammad Rizki
2025-02-22 22:54 ` [PATCH v1 09/13] chore(ui/avatar): add select-none for avatar fallback Muhammad Rizki
2025-02-22 22:54 ` [PATCH v1 10/13] chore(deps): upgrade bits-ui version Muhammad Rizki
2025-02-22 22:54 ` [PATCH v1 11/13] feat(ui): add radio-group ui Muhammad Rizki
2025-02-22 22:54 ` [PATCH v1 12/13] feat(sidebar-menu): add Roundcube link Muhammad Rizki
2025-02-22 22:54 ` Muhammad Rizki [this message]
2025-02-23  8:27 ` [PATCH v1 00/13] Add Profile & Account Management Alviro Iskandar Setiawan
2025-02-23  8:43 ` Ammar Faizi
2025-02-23  8:52   ` Alviro Iskandar Setiawan
2025-02-23  8:55     ` Ammar Faizi
2025-02-23  8:57       ` Alviro Iskandar Setiawan

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    [email protected] \
    [email protected] \
    [email protected] \
    [email protected] \
    [email protected] \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox