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/17] refactor!:feat: update API response structure, update profile page
Date: Wed,  5 Mar 2025 21:40:12 +0700	[thread overview]
Message-ID: <[email protected]> (raw)
In-Reply-To: <[email protected]>

- Update API response structure to match the new API response structure.
- Update profile page to follow the user profile schema and its types.
- Update login page to use the new API response structure.
- Update the profile page to use the new API response structure.

Signed-off-by: Muhammad Rizki <[email protected]>
---
 src/lib/hooks/auth.svelte.ts                  |  12 +-
 src/lib/schemas/profile-schema.ts             |  20 +-
 src/lib/typings/credential.d.ts               |  11 +-
 src/lib/typings/http.d.ts                     |  11 +-
 src/routes/(protected)/+layout.ts             |   7 +-
 .../(protected)/settings/profile/+page.svelte | 577 +++++++++++++-----
 .../(protected)/settings/profile/+page.ts     |   7 +-
 src/routes/+page.svelte                       |  17 +-
 8 files changed, 498 insertions(+), 164 deletions(-)

diff --git a/src/lib/hooks/auth.svelte.ts b/src/lib/hooks/auth.svelte.ts
index ba357f7..7bd2d3b 100644
--- a/src/lib/hooks/auth.svelte.ts
+++ b/src/lib/hooks/auth.svelte.ts
@@ -19,7 +19,7 @@ export function useAuth() {
       return data.user_info;
     },
 
-    set token(newValue: string) {
+    set token(newValue) {
       data.token = newValue;
     },
 
@@ -28,11 +28,11 @@ export function useAuth() {
       data.user_info = JSON.parse(user!) as User;
     },
 
-    save(newData: LoginResponse) {
-      data = newData;
-      localStorage.setItem("gwm_token", newData.token);
-      localStorage.setItem("gwm_token_exp_at", newData.token_exp_at.toString());
-      localStorage.setItem("gwm_uinfo", JSON.stringify(newData.user_info));
+    save({ user_info, token, token_exp_at }: LoginResponse) {
+      data = { user_info, token, token_exp_at };
+      localStorage.setItem("gwm_token", token || "null");
+      localStorage.setItem("gwm_token_exp_at", token_exp_at!.toString() ?? "0");
+      localStorage.setItem("gwm_uinfo", JSON.stringify(user_info));
     },
 
     clear() {
diff --git a/src/lib/schemas/profile-schema.ts b/src/lib/schemas/profile-schema.ts
index fa67c17..c4c7228 100644
--- a/src/lib/schemas/profile-schema.ts
+++ b/src/lib/schemas/profile-schema.ts
@@ -1,8 +1,22 @@
 import { z } from "zod";
 
+const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
+
+const photoSchema = z.instanceof(File).refine((file) => file.size <= MAX_FILE_SIZE, {
+  message: "File size must be less than 10MB"
+});
+
 export const profileSchema = z.object({
-  avatar: z.instanceof(File).optional(),
+  photo: photoSchema.nullable(),
   username: z.string().optional(),
-  full_name: z.string().optional(),
-  gender: z.string().optional()
+  full_name: z.string().min(1, "Full name is required"),
+  ext_email: z.string().email("Invalid email format"),
+  gender: z.enum(["m", "f"]),
+  socials: z.object({
+    github_username: z.string().optional(),
+    telegram_username: z.string().optional(),
+    twitter_username: z.string().optional(),
+    discord_username: z.string().optional()
+  }),
+  password: z.string()
 });
diff --git a/src/lib/typings/credential.d.ts b/src/lib/typings/credential.d.ts
index df02c86..beabe5e 100644
--- a/src/lib/typings/credential.d.ts
+++ b/src/lib/typings/credential.d.ts
@@ -1,7 +1,16 @@
 export interface User {
   user_id: number;
   full_name: string;
-  gender: string;
+  gender: Gender;
   username: string;
+  ext_email: string;
   role: string;
+  is_active: IsActive | boolean;
+  socials: {
+    github_username: string;
+    telegram_username: string;
+    twitter_username: string;
+    discord_username: string;
+  };
+  photo: string;
 }
diff --git a/src/lib/typings/http.d.ts b/src/lib/typings/http.d.ts
index 98d64f5..1d390a3 100644
--- a/src/lib/typings/http.d.ts
+++ b/src/lib/typings/http.d.ts
@@ -2,7 +2,10 @@ import type { User } from "./credential";
 
 export interface ResponseAPI<Data> {
   code: number;
-  res?: Data & { renew_token?: RenewTokenResponse };
+  res?: Data & {
+    msg: string;
+    renew_token?: RenewTokenResponse;
+  };
 }
 
 export interface RenewTokenResponse {
@@ -11,7 +14,7 @@ export interface RenewTokenResponse {
 }
 
 export interface LoginResponse {
-  token: string;
-  token_exp_at: number;
-  user_info?: User | null;
+  token?: string;
+  token_exp_at?: number;
+  user_info?: User;
 }
diff --git a/src/routes/(protected)/+layout.ts b/src/routes/(protected)/+layout.ts
index 91dbfc7..06ff2db 100644
--- a/src/routes/(protected)/+layout.ts
+++ b/src/routes/(protected)/+layout.ts
@@ -16,6 +16,9 @@ export const load: LayoutLoad = async () => {
     params: { action: "get_user_info" }
   });
 
-  localStorage.setItem("gwm_uinfo", JSON.stringify(data.res?.user_info));
-  auth.refresh();
+  auth.save({
+    token: data.res?.renew_token?.token,
+    token_exp_at: data.res?.renew_token?.token_exp_at,
+    user_info: data.res?.user_info
+  });
 };
diff --git a/src/routes/(protected)/settings/profile/+page.svelte b/src/routes/(protected)/settings/profile/+page.svelte
index 73edb8a..b91382a 100644
--- a/src/routes/(protected)/settings/profile/+page.svelte
+++ b/src/routes/(protected)/settings/profile/+page.svelte
@@ -1,170 +1,469 @@
 <script lang="ts">
   import { useAuth } from "$lib/hooks/auth.svelte";
-  import { superForm } from "sveltekit-superforms";
+  import { setError, setMessage, superForm } from "sveltekit-superforms";
   import { zodClient } from "sveltekit-superforms/adapters";
   import { profileSchema } from "$lib/schemas/profile-schema";
   import Pencil from "lucide-svelte/icons/pencil";
   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 * as Popover from "$lib/components/ui/popover";
+  import * as Dialog from "$lib/components/ui/dialog";
   import Input from "$components/ui/input/input.svelte";
   import Label from "$components/ui/label/label.svelte";
+  import IconGithub from "$components/icons/icon-github.svelte";
+  import IconTelegram from "$components/icons/icon-telegram.svelte";
+  import IconTwitter from "$components/icons/icon-twitter.svelte";
+  import IconDiscord from "$components/icons/icon-discord.svelte";
+  import Loading from "$components/customs/loading.svelte";
+  import Button from "$components/ui/button/button.svelte";
+  import http from "$lib/hooks/http.svelte.js";
+  import * as typing from "$typings";
+  import { toast } from "svelte-sonner";
+  import InputPassword from "$components/ui/input/input-password.svelte";
 
   let { data } = $props();
-
-  const auth = useAuth();
-
-  let avatarImage = $state<string>();
+  let showModalConfirmation = $state(false);
 
   const form = superForm(data.form, {
     SPA: true,
-    validators: zodClient(profileSchema)
+    resetForm: false,
+    validators: zodClient(profileSchema),
+    dataType: "json",
+
+    async onUpdate({ form }) {
+      const formData = new FormData();
+      if (form.data.photo) {
+        formData.append("photo", form.data.photo);
+      }
+      formData.append("full_name", form.data.full_name);
+      formData.append("ext_email", form.data.ext_email);
+      formData.append("gender", form.data.gender);
+      formData.append("password", form.data.password);
+      if (form.data.socials.github_username) {
+        formData.append("socials[github_username]", form.data.socials.github_username);
+      }
+      if (form.data.socials.telegram_username) {
+        formData.append("socials[telegram_username]", form.data.socials.telegram_username);
+      }
+      if (form.data.socials.twitter_username) {
+        formData.append("socials[twitter_username]", form.data.socials.twitter_username);
+      }
+      if (form.data.socials.discord_username) {
+        formData.append("socials[discord_username]", form.data.socials.discord_username);
+      }
+
+      const {
+        data: { res },
+        status
+      } = await http<typing.ResponseAPI<{}>>({
+        params: { action: "set_user_info" },
+        method: "POST",
+        data: formData
+      });
+
+      if (status === 200) {
+        const { data } = await http<{ user_info: typing.User }>({
+          params: { action: "get_user_info" }
+        });
+
+        auth.save({
+          token: data.res?.renew_token?.token,
+          token_exp_at: data.res?.renew_token?.token_exp_at,
+          user_info: data.res?.user_info
+        });
+
+        toast.info("Success update profile", {
+          description: data.res?.msg ?? "Invalid credential, please login again."
+        });
+      } else {
+        if (res?.msg.includes("full_name")) {
+          setError(form, "full_name", res?.msg);
+        }
+
+        if (res?.msg.includes("ext_email")) {
+          setError(form, "ext_email", res?.msg);
+        }
+
+        if (res?.msg.includes("gender")) {
+          setError(form, "gender", res?.msg);
+        }
+
+        if (res?.msg.includes("password")) {
+          setError(form, "password", res?.msg);
+        }
+
+        toast.error("Failed to update profile", {
+          description: res?.msg ?? "Invalid credential, please login again."
+        });
+      }
+    }
   });
 
+  const { form: formData, errors, submitting, constraints, enhance, submit } = form;
+
+  const auth = useAuth();
+
+  let avatarImg = $state(data.avatar);
+  const avatar = $derived(avatarImg);
+
   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 handleOpenModal = (e: boolean) => {
+    showModalConfirmation = e;
+  };
+
+  const handleAvatar = (event: Event) => {
+    const input = event.target as HTMLInputElement;
+    const file = input.files?.[0];
+    if (!file) return;
+
+    $formData.photo = file;
+
     const reader = new FileReader();
+    reader.onload = () => {
+      avatarImg = reader.result as string;
+    };
     reader.readAsDataURL(file);
+  };
 
-    reader.onload = function () {
-      avatarImage = reader.result as string;
-    };
+  const handleSubmit = () => {
+    submit();
+    handleOpenModal(false);
   };
 
-  const { form: formData, errors, submitting, constraints, enhance } = form;
+  const isSubmittable = $derived(
+    Boolean($formData.full_name && $formData.ext_email && $formData.gender)
+  );
+
+  const isError = $derived(Boolean($errors.full_name || $errors.ext_email || $errors.gender));
 </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" class="relative">
-      <Form.Control>
-        {#snippet children({ props })}
-          <Form.Label for="avatar" class="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
+<Dialog.Root open={showModalConfirmation} onOpenChange={handleOpenModal}>
+  <form use:enhance class="flex flex-col gap-5" enctype="multipart/form-data">
+    <div
+      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="photo" class="relative text-center">
+          <Form.Control>
+            {#snippet children({ props })}
+              <Form.Label for="photo" class="cursor-pointer space-y-2">
+                <span>Profile picture</span>
+                <Avatar.Root class="size-40">
+                  <Avatar.Image src={avatar} alt="@{auth.user?.username}" />
+                  <Avatar.Fallback class="text-xl">
+                    {#if !Boolean(avatar)}
+                      {getShortName()}
+                    {:else}
+                      <Loading class="size-7 text-black" />
+                    {/if}
+                  </Avatar.Fallback>
+                </Avatar.Root>
+                <Popover.Root>
+                  <Popover.Trigger
+                    class="absolute bottom-3 left-0 flex h-max w-max items-center gap-x-1 rounded-lg border border-input bg-background px-2 py-1 hover:bg-accent hover:text-accent-foreground"
+                  >
+                    <Pencil class="size-4" />
+                    <span class="text-xs font-medium">Edit</span>
+                  </Popover.Trigger>
+                  <Popover.Content class="flex max-w-[8rem] flex-col gap-y-1 p-1 text-sm">
+                    <Button variant="ghost" class="h-max w-full px-0 py-0">
+                      <Form.Label
+                        for="photo"
+                        class="w-full cursor-pointer rounded-md px-2 py-1.5 text-start text-xs"
+                      >
+                        Upload...
+                      </Form.Label>
+                    </Button>
+                    <Button
+                      variant="ghost"
+                      class="flex h-max w-full justify-start px-2 py-1.5 text-xs"
+                    >
+                      Delete
+                    </Button>
+                  </Popover.Content>
+                </Popover.Root>
+              </Form.Label>
+              <Input
+                type="file"
+                accept="image/png,image/jpeg"
+                {...props}
+                aria-invalid={$errors.photo ? "true" : undefined}
+                disabled={$submitting}
+                onchange={handleAvatar}
+                {...$constraints.photo}
+                class="hidden"
+              />
+            {/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 for="username">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="ext_email">
+          <Form.Control>
+            {#snippet children({ props })}
+              <Form.Label for="ext_email">External Email</Form.Label>
+              <Input
+                {...props}
+                aria-invalid={$errors.ext_email ? "true" : undefined}
+                bind:value={$formData.ext_email}
+                placeholder="Your external email address"
+                disabled={$submitting}
+                {...$constraints.ext_email}
+              />
+              <Form.FieldErrors />
+              <Form.Description>
+                This is your external email address e.g [email protected]
+              </Form.Description>
+            {/snippet}
+          </Form.Control>
+        </Form.Field>
+
+        <Form.Field {form} name="full_name">
+          <Form.Control>
+            {#snippet children({ props })}
+              <Form.Label for="full_name">Full Name</Form.Label>
+              <Input
+                {...props}
+                aria-invalid={$errors.full_name ? "true" : undefined}
+                bind:value={$formData.full_name}
+                placeholder="Your full name"
+                disabled={$submitting}
+                {...$constraints.full_name}
+              />
+              <Form.FieldErrors />
+              <Form.Description>You full name must be your real name.</Form.Description>
+            {/snippet}
+          </Form.Control>
+        </Form.Field>
+
+        <Form.Field {form} name="gender">
+          <Form.Control>
+            {#snippet children({ props })}
+              <Form.Label for="gender">Gender</Form.Label>
+              <RadioGroup.Root
+                {...props}
+                aria-invalid={$errors.gender ? "true" : undefined}
+                bind:value={$formData.gender}
+                placeholder="Your full name"
+                disabled={$submitting}
+                {...$constraints.gender}
+              >
+                <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>
+              <Form.FieldErrors />
+              <Form.Description>
+                Your real gender, <b><u><i>NO LGBT</i></u></b>.
+              </Form.Description>
+            {/snippet}
+          </Form.Control>
+        </Form.Field>
+
+        <div class="space-y-1">
+          <span
+            class="select-none text-sm font-medium {Boolean($errors.socials) && 'text-destructive'}"
           >
-            <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" class="relative">
-      <Form.Control>
-        {#snippet children({ props })}
-          <Form.Label for="avatar" class="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>
+            Social Accounts
+          </span>
+          <div class="flex items-center gap-x-2">
+            <IconGithub href="https://github.com/{$formData.socials.github_username}" />
+            <Form.Field {form} name="socials.github_username" class="w-full">
+              <Form.Control>
+                {#snippet children({ props })}
+                  <Input
+                    {...props}
+                    aria-invalid={$errors.socials?.github_username ? "true" : undefined}
+                    bind:value={$formData.socials.github_username}
+                    placeholder="Your github username"
+                    disabled={$submitting}
+                    {...$constraints.socials?.github_username}
+                  />
+                {/snippet}
+              </Form.Control>
+            </Form.Field>
+          </div>
+          <div class="flex items-center gap-x-2">
+            <IconTelegram href="https://{$formData.socials.telegram_username}.t.me" />
+            <Form.Field {form} name="socials.telegram_username" class="w-full">
+              <Form.Control>
+                {#snippet children({ props })}
+                  <Input
+                    {...props}
+                    aria-invalid={$errors.socials?.telegram_username ? "true" : undefined}
+                    bind:value={$formData.socials.telegram_username}
+                    placeholder="Your telegram username"
+                    disabled={$submitting}
+                    {...$constraints.socials?.telegram_username}
+                  />
+                {/snippet}
+              </Form.Control>
+            </Form.Field>
+          </div>
+          <div class="flex items-center gap-x-2">
+            <IconTwitter href="https://x.com/{$formData.socials.twitter_username}" />
+            <Form.Field {form} name="socials.twitter_username" class="w-full">
+              <Form.Control>
+                {#snippet children({ props })}
+                  <Input
+                    {...props}
+                    aria-invalid={$errors.socials?.twitter_username ? "true" : undefined}
+                    bind:value={$formData.socials.twitter_username}
+                    placeholder="Your twitter username"
+                    disabled={$submitting}
+                    {...$constraints.socials?.twitter_username}
+                  />
+                {/snippet}
+              </Form.Control>
+            </Form.Field>
+          </div>
+          <div class="flex items-center gap-x-2">
+            <IconDiscord />
+            <Form.Field {form} name="socials.discord_username" class="w-full">
+              <Form.Control>
+                {#snippet children({ props })}
+                  <Input
+                    {...props}
+                    aria-invalid={$errors.socials?.discord_username ? "true" : undefined}
+                    bind:value={$formData.socials.discord_username}
+                    placeholder="Your discord username"
+                    disabled={$submitting}
+                    {...$constraints.socials?.discord_username}
+                  />
+                {/snippet}
+              </Form.Control>
+            </Form.Field>
+          </div>
+        </div>
+      </div>
+
+      <div class="hidden lg:block">
+        <Form.Field {form} name="photo" class="relative text-center">
+          <Form.Control>
+            {#snippet children({ props })}
+              <Form.Label for="photo" class="cursor-pointer space-y-2">
+                <span>Profile picture</span>
+                <Avatar.Root class="lg:size-40 xl:size-52">
+                  <Avatar.Image src={avatar} alt="@{auth.user?.username}" />
+                  <Avatar.Fallback class="lg:text-xl xl:text-3xl">
+                    {#if !Boolean(avatar)}
+                      {getShortName()}
+                    {:else}
+                      <Loading class="size-10 text-black" />
+                    {/if}
+                  </Avatar.Fallback>
+                </Avatar.Root>
+                <Popover.Root>
+                  <Popover.Trigger
+                    class="absolute bottom-3 left-0 flex h-max w-max items-center gap-x-1 rounded-lg border border-input bg-background px-2 py-1 hover:bg-accent hover:text-accent-foreground xl:left-2.5"
+                  >
+                    <Pencil class="size-4" />
+                    <span class="text-xs font-medium">Edit</span>
+                  </Popover.Trigger>
+                  <Popover.Content class="flex max-w-[8rem] flex-col gap-y-1 p-1 text-sm">
+                    <Button variant="ghost" class="h-max w-full px-0 py-0">
+                      <Form.Label
+                        for="photo"
+                        class="w-full cursor-pointer rounded-md px-2 py-1.5 text-start text-xs"
+                      >
+                        Upload...
+                      </Form.Label>
+                    </Button>
+                    <Button
+                      variant="ghost"
+                      class="flex h-max w-full justify-start px-2 py-1.5 text-xs"
+                    >
+                      Delete
+                    </Button>
+                  </Popover.Content>
+                </Popover.Root>
+              </Form.Label>
+              <Input
+                type="file"
+                accept="image/png,image/jpeg"
+                {...props}
+                aria-invalid={$errors.photo ? "true" : undefined}
+                disabled={$submitting}
+                onchange={handleAvatar}
+                {...$constraints.photo}
+                class="hidden"
+              />
+            {/snippet}
+          </Form.Control>
+        </Form.Field>
+      </div>
+    </div>
+
+    <Dialog.Trigger>
+      {#snippet child({ props })}
+        <Button
+          type="button"
+          class="w-max px-5"
+          disabled={$submitting || !isSubmittable || isError}
+          {...props}
+        >
+          Update profile {isSubmittable}
+        </Button>
+      {/snippet}
+    </Dialog.Trigger>
+
+    <Dialog.Content class="sm:max-w-[425px]">
+      <Dialog.Header>
+        <Dialog.Title>Update Profile Confirmation</Dialog.Title>
+        <Dialog.Description>Confirm changes to your profile here.</Dialog.Description>
+      </Dialog.Header>
+      <div>
+        <Form.Field {form} name="password" class="w-full">
+          <Form.Control>
+            {#snippet children({ props })}
+              <Form.Label for="password">Password</Form.Label>
+              <InputPassword
+                {...props}
+                aria-invalid={$errors.password ? "true" : undefined}
+                bind:value={$formData.password}
+                placeholder="Enter password"
+                disabled={$submitting}
+                {...$constraints.password}
+              />
+              <Form.Description>
+                Your password is required to make changes to your profile.
+              </Form.Description>
+            {/snippet}
+          </Form.Control>
+        </Form.Field>
+      </div>
+      <Dialog.Footer>
+        <Button type="submit" onclick={handleSubmit}>Confirm</Button>
+      </Dialog.Footer>
+    </Dialog.Content>
+  </form>
+</Dialog.Root>
diff --git a/src/routes/(protected)/settings/profile/+page.ts b/src/routes/(protected)/settings/profile/+page.ts
index 9e036c8..7b25a4f 100644
--- a/src/routes/(protected)/settings/profile/+page.ts
+++ b/src/routes/(protected)/settings/profile/+page.ts
@@ -11,10 +11,11 @@ export const load: PageLoad = async () => {
   const data = {
     username: auth.user?.username,
     full_name: auth.user?.full_name,
-    gender: auth.user?.gender
+    ext_email: auth.user?.ext_email,
+    gender: auth.user?.gender,
+    socials: auth.user?.socials
   };
 
   const form = await superValidate(data, zod(profileSchema));
-
-  return { form };
+  return { form, avatar: auth.user?.photo };
 };
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index eeadde5..cb4f673 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -22,7 +22,10 @@
     validators: zod(loginSchema),
 
     async onUpdate({ form }) {
-      const res = await http<typing.LoginResponse>({
+      const {
+        status,
+        data: { res }
+      } = await http<typing.LoginResponse>({
         params: { action: "login" },
         method: "POST",
         data: {
@@ -31,12 +34,12 @@
         }
       });
 
-      if (res.status === 200) {
-        auth.save(res.data.res!);
+      if (status === 200) {
+        auth.save(res as typing.LoginResponse);
       } else {
         setError(form, "username_or_email", "");
         setError(form, "password", "");
-        setMessage(form, res.data.res);
+        setMessage(form, res?.msg ?? "Invalid credential, please login again.");
       }
     }
   });
@@ -57,8 +60,10 @@
   <Card.Root class="w-full max-w-lg">
     <form method="POST" use:enhance>
       <Card.Header class="flex items-center justify-center space-y-1">
-        <Card.Title class="text-xl text-center lg:text-2xl">GNU/Weeb Mail Login</Card.Title>
-        <Card.Description class="text-center">Proceed login to manage your email account</Card.Description>
+        <Card.Title class="text-center text-xl lg:text-2xl">GNU/Weeb Mail Login</Card.Title>
+        <Card.Description class="text-center">
+          Proceed login to manage your email account
+        </Card.Description>
 
         {#if isError() && !isCredentialInvalid()}
           <span class="text-sm font-medium text-destructive">
-- 
Muhammad Rizki


  parent reply	other threads:[~2025-03-05 14:41 UTC|newest]

Thread overview: 29+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-03-05 14:39 [PATCH v1 00/17] Profile Page, SEO, Fixed API structure, Docs Muhammad Rizki
2025-03-05 14:40 ` [PATCH v1 01/17] fix(typing): add user_info type prop Muhammad Rizki
2025-03-05 14:40 ` [PATCH v1 02/17] refactor: optimize icon imports to reduce bundle size Muhammad Rizki
2025-03-05 14:40 ` [PATCH v1 03/17] chore(change-pwd): adjust change password heading styling Muhammad Rizki
2025-03-05 14:40 ` [PATCH v1 04/17] chore(settings/layout): use prose: for " Muhammad Rizki
2025-03-05 14:40 ` [PATCH v1 05/17] fix(profile): fix edit avatar button position Muhammad Rizki
2025-03-05 14:40 ` [PATCH v1 06/17] fix(breadcrumb): Move settingsNav to settings items navigations Muhammad Rizki
2025-03-05 14:40 ` [PATCH v1 07/17] chore(responsive): adjust styling Muhammad Rizki
2025-03-05 14:40 ` [PATCH v1 08/17] chore(navigations): Replace index /settings url Muhammad Rizki
2025-03-05 14:40 ` [PATCH v1 09/17] feat(ui): Add popover and dialog UI component Muhammad Rizki
2025-03-05 14:40 ` [PATCH v1 10/17] feat(http): Use PUBLIC_BASE_URL for each environment Muhammad Rizki
2025-03-05 14:40 ` [PATCH v1 11/17] feat(icons): Add social icons Muhammad Rizki
2025-03-05 14:40 ` [PATCH v1 12/17] feat(typing/enum): add Gender and IsActive enum Muhammad Rizki
2025-03-05 14:40 ` Muhammad Rizki [this message]
2025-03-05 14:40 ` [PATCH v1 14/17] chore(meta): rename favicon.png to favicon.ico Muhammad Rizki
2025-03-05 14:40 ` [PATCH v1 15/17] feat(seo): add SEO for site metadata Muhammad Rizki
2025-03-05 14:40 ` [PATCH v1 16/17] chore(login): use $derived() instead of function based Muhammad Rizki
2025-03-05 14:40 ` [PATCH v1 17/17] docs: update README.md Muhammad Rizki
2025-03-05 16:54 ` [PATCH v1 00/17] Profile Page, SEO, Fixed API structure, Docs Ammar Faizi
2025-03-05 16:57   ` Alviro Iskandar Setiawan
2025-03-06  7:01     ` Muhammad Rizki
2025-03-06  7:02       ` Alviro Iskandar Setiawan
2025-03-06  7:04         ` Muhammad Rizki
2025-03-06  2:02   ` Muhammad Rizki
2025-03-06  3:37     ` Ammar Faizi
2025-03-05 17:04 ` Ammar Faizi
2025-03-05 18:14 ` Alviro Iskandar Setiawan
2025-03-06  1:59   ` Muhammad Rizki
2025-03-06  3:35     ` Ammar Faizi

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