Skip to content

Commit

Permalink
Add better avatar edit
Browse files Browse the repository at this point in the history
  • Loading branch information
zobweyt committed Sep 14, 2024
1 parent 48bd5ac commit ea22e27
Show file tree
Hide file tree
Showing 28 changed files with 306 additions and 226 deletions.
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@solid-primitives/i18n": "^2.1.1",
"@solid-primitives/intersection-observer": "^2.1.6",
"@solid-primitives/storage": "^4.2.1",
"@solid-primitives/upload": "^0.0.117",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.14.3",
"@solidjs/start": "^1.0.6",
Expand Down
13 changes: 13 additions & 0 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

72 changes: 72 additions & 0 deletions frontend/src/components/avatar-edit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { createFileUploader } from "@solid-primitives/upload";
import { useAction, useSubmission } from "@solidjs/router";
import { Icon } from "solid-heroicons";
import { arrowTopRightOnSquare, arrowUpTray, camera, trash } from "solid-heroicons/solid-mini";
import { Component, Show } from "solid-js";
import { toast } from "solid-sonner";
import { Avatar, Dropdown, Separator } from "~/components";
import { components } from "~/lib/api/schema";
import { formatResourceURL } from "~/lib/api/uploads";
import { removeCurrentUserAvatar, updateCurrentUserAvatar } from "~/lib/api/users/me";
import { useI18n } from "~/lib/i18n";

export type AvatarEditProps = {
user: components["schemas"]["CurrentUserResponse"];
};

export const AvatarEdit: Component<AvatarEditProps> = (props) => {
const i18n = useI18n();

const updateAvatar = useAction(updateCurrentUserAvatar);
const updatingAvatar = useSubmission(updateCurrentUserAvatar);
const uploader = createFileUploader({ accept: "image/*" });

const removeAvatar = useAction(removeCurrentUserAvatar);
const removingAvatar = useSubmission(removeCurrentUserAvatar);

const selectAvatar = () => {
uploader.selectFiles(async (uploads) => {
const result = await updateAvatar(uploads[0].file);
if (result?.error) {
toast.error(result.error.detail?.toString());
}
});
};

return (
<Dropdown placement="bottom">
<Dropdown.Trigger as={Avatar} aria-busy={updatingAvatar.pending || removingAvatar.pending}>
<Avatar.Img src={props.user.avatar_url} alt={props.user.email} />
<div class="absolute inset-0 flex flex-col items-center justify-center bg-black/50 font-semibold text-white opacity-0 transition-opacity hover:opacity-100">
<Icon path={camera} class="size-8" />
<span>{i18n.t.components.avatarEdit.update()}</span>
</div>
</Dropdown.Trigger>
<Dropdown.Content>
<Show when={props.user.avatar_url}>
{(avatar_url) => (
<>
<Dropdown.Item onSelect={() => window.open(formatResourceURL(avatar_url()), "_blank")}>
<Dropdown.ItemIcon path={arrowTopRightOnSquare} class="size-4" />
<Dropdown.ItemLabel>{i18n.t.components.avatarEdit.open()}</Dropdown.ItemLabel>
</Dropdown.Item>
<Separator orientation="horizontal" class="my-0.5" />
</>
)}
</Show>
<Dropdown.Item onSelect={selectAvatar} disabled={updatingAvatar.pending}>
<Dropdown.ItemIcon path={arrowUpTray} class="size-4" />
<Dropdown.ItemLabel>{i18n.t.components.avatarEdit.select()}</Dropdown.ItemLabel>
</Dropdown.Item>
<Dropdown.Item
class="text-red-600"
onSelect={removeAvatar}
disabled={!props.user.avatar_url || removingAvatar.pending}
>
<Dropdown.ItemIcon path={trash} class="size-4" />
<Dropdown.ItemLabel>{i18n.t.components.avatarEdit.remove()}</Dropdown.ItemLabel>
</Dropdown.Item>
</Dropdown.Content>
</Dropdown>
);
};
1 change: 1 addition & 0 deletions frontend/src/components/index.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./ui";
export * from "./session-expiration-observer";
export * from "./avatar-edit";
4 changes: 2 additions & 2 deletions frontend/src/components/session-expiration-observer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ export const SessionExpirationObserver: ParentComponent = (props) => {
revalidate([getIsLoggedIn.key, getSessionExpirationDate.key, getCurrentUser.key]);
};

// BUG: When logging out, toast infinitely shows up on iOS until logged in.
// BUG: When logging out, toast infinitely shows up on mobile until logged in.
const toastSessionExpired = (): void => {
if (isLoggedIn() === false) {
toast.info(i18n.t.sessionExpired());
toast.info(i18n.t.components.sessionExpirationObserver.expired());
}
};

Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/steps/new-password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ export function NewPasswordStep() {
return;
}

const changed = await reset({ email: context.store.email, code: context.store.otp, password: form.password });
const result = await reset({ email: context.store.email, code: context.store.otp, password: form.password });

if (changed) {
toast.success(i18n.t.steps.resetPassword.password.form.success());
if (result?.error) {
toast.error(result.error.detail?.toString());
} else {
toast.error(i18n.t.steps.resetPassword.password.form.errors.unknown());
toast.success(i18n.t.steps.resetPassword.password.form.success());
}
};

Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/steps/verification/otp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ export const OtpStep = () => {

const onSubmit = async (form: OtpForm) => {
const otp = Number(form.otp);
const result = await isCorrectOtp({ email: email(), code: otp });

if (!(await isCorrectOtp({ email: email(), code: otp }))) {
toast.error(i18n.t.steps.verification.otp.incorrect());

if (result.error) {
toast.error(result.error.detail?.toString());
reset();
} else {
context.setStore("otp", otp);
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/components/ui/avatar/avatar.props.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { JSX, ValidComponent } from "solid-js";
import type { ImageRootProps, ImageImgProps } from "@kobalte/core/image";

export type AvatarRootProps<T extends ValidComponent = "span"> = ImageRootProps<T> & JSX.StylableSVGAttributes;
export type AvatarImgProps<T extends ValidComponent = "img"> = Omit<ImageImgProps<T>, "src"> &
JSX.StylableSVGAttributes & {
alt: string;
src: string | null | undefined;
};
17 changes: 17 additions & 0 deletions frontend/src/components/ui/avatar/avatar.styles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { tv } from "tailwind-variants";

export const styles = tv({
slots: {
root: [
"group relative inline-flex size-24 items-center justify-center container-size",
"select-none overflow-hidden rounded-full text-fg-soft ring-1 ring-bg-tertiary",
],
img: ["group-aria-busy:cursor-progress group-aria-busy:animate-pulse"],
alt: [
"flex size-full items-center justify-center",
"bg-bg-default text-cqi-50 font-medium uppercase leading-none",
"group-aria-busy:cursor-progress group-aria-busy:animate-pulse",
],
fallback: ["size-full animate-pulse bg-bg-default"],
},
});
42 changes: 42 additions & 0 deletions frontend/src/components/ui/avatar/avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { type ValidComponent, Show, splitProps } from "solid-js";
import { Transition } from "solid-transition-group";

import { Image } from "@kobalte/core/image";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";

import { formatResourceURL } from "~/lib/api/uploads";
import { merge } from "~/lib/utils/css/merge";

import type { AvatarImgProps, AvatarRootProps } from "./avatar.props";
import { styles } from "./avatar.styles";

export const AvatarRoot = <T extends ValidComponent = "span">(props: PolymorphicProps<T, AvatarRootProps<T>>) => {
const [local, others] = splitProps(props as AvatarRootProps<T>, ["class"]);

return <Image class={merge(styles().root(), local.class)} {...others} />;
};

export const AvatarImg = <T extends ValidComponent = "img">(props: PolymorphicProps<T, AvatarImgProps<T>>) => {
const [local, others] = splitProps(props as AvatarImgProps<T>, ["class", "src"]);

return (
<Transition
mode="outin"
enterClass="blur-md"
enterToClass="blur-0"
exitClass="blur-0"
exitToClass="blur-md"
enterActiveClass="transition-filter"
exitActiveClass="transition-filter"
>
<Show when={local.src} fallback={<span class={styles().alt()}>{props.alt.slice(0, 2)}</span>}>
{(src) => (
<span>
<Image.Img src={formatResourceURL(src())} class={merge(styles().img(), local.class)} {...others} />
<Image.Fallback class={styles().fallback()} />
</span>
)}
</Show>
</Transition>
);
};
5 changes: 5 additions & 0 deletions frontend/src/components/ui/avatar/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AvatarImg, AvatarRoot } from "./avatar";

export const Avatar = Object.assign(AvatarRoot, {
Img: AvatarImg,
});
2 changes: 2 additions & 0 deletions frontend/src/components/ui/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./avatar";
export * from "./button";
export * from "./cursor";
export * from "./dropdown";
Expand All @@ -9,6 +10,7 @@ export * from "./motion";
export * from "./otp-field";
export * from "./select";
export * from "./separator";
export * from "./settings";
export * from "./sidebar";
export * from "./stepper";
export * from "./text-field";
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/ui/sidebar/item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ export const SidebarItemRoot: ParentComponent<AnchorProps> = (props) => {
return (
<A
class={merge(
"text-fg-soft flex w-full items-center justify-center gap-1",
"transition-[opacity,color,background-color] active:duration-0",
"flex w-full select-none items-center justify-center gap-1 text-fg-soft",
"transition-[opacity,color] active:duration-0",
"max-md:hover:opacity-75 max-md:active:opacity-50",
"max-sm:flex-col",
"md:flex-row md:justify-start md:gap-2 md:rounded-lg md:p-2 md:hover:bg-bg-secondary",
local.class,
)}
activeClass={merge(
"max-md:text-fg-accent md:text-fg-body md:bg-bg-tertiary md:hover:bg-bg-tertiary",
"max-md:text-fg-accent md:text-fg-body md:bg-bg-tertiary md:hover:bg-bg-tertiary !transition-[opacity,color,background-color]",
local.activeClass,
)}
end
Expand Down
48 changes: 21 additions & 27 deletions frontend/src/components/ui/sidebar/root.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { createAsync } from "@solidjs/router";
import { Component, Show } from "solid-js";
import { Component, Show, splitProps } from "solid-js";

import { bookmark, cog, folder, home, userCircle } from "solid-heroicons/solid-mini";
import { bookmark, cog, folder, home } from "solid-heroicons/solid-mini";

import { formatResourceURL } from "~/lib/api/uploads";
import { getCurrentUser } from "~/lib/api/users/me";
import { useI18n } from "~/lib/i18n";

import { Avatar } from "../avatar";
import { SidebarItem } from "./item";
import { merge } from "~/lib/utils/css/merge";

export const Sidebar: Component = () => {
const i18n = useI18n();
Expand All @@ -28,31 +29,24 @@ export const Sidebar: Component = () => {
<SidebarItem.Icon path={folder} />
<SidebarItem.Label>Library</SidebarItem.Label>
</SidebarItem>
<Show
when={currentUser()}
fallback={
<SidebarItem href="/login" class="flex md:mt-auto">
<SidebarItem.Icon path={userCircle} />
<SidebarItem.Label>{i18n.t.routes.login.title()}</SidebarItem.Label>
</SidebarItem>
}
>
{(user) => (
<SidebarItem href="/settings" class="flex md:mt-auto" activeClass="max-md:[&>img]:ring-blue-500">
<Show when={user().avatar_url} fallback={<SidebarItem.Icon path={cog} />}>
{(avatar_url) => (
<SidebarItem.Icon
as={"img"}
src={formatResourceURL(avatar_url())}
class="rounded-full ring-1 max-md:ring-bg-tertiary max-md:transition-shadow"
/>
)}
</Show>
<SidebarItem href="/settings" class="group md:mt-auto">
<Show when={currentUser()} fallback={<SidebarItem.Icon path={cog} />}>
{(user) => (
<SidebarItem.Icon
as={(props) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<Avatar class={merge("group-aria-[current='page']:max-md:ring-fg-accent", local.class)} {...rest}>
<Avatar.Img src={user().avatar_url} alt={user().email} />
</Avatar>
);
}}
/>
)}
</Show>

<SidebarItem.Label>{i18n.t.routes.settings.heading()}</SidebarItem.Label>
</SidebarItem>
)}
</Show>
<SidebarItem.Label>{i18n.t.routes.settings.heading()}</SidebarItem.Label>
</SidebarItem>
</nav>
</aside>
);
Expand Down
13 changes: 6 additions & 7 deletions frontend/src/lib/api/auth/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@ import { $authenticate, $resetPassword } from "./service";
export const authenticate = action(async (form: LoginForm) => {
"use server";

const { data, error, status } = await $authenticate(form.email, form.password);
const { data, error } = await $authenticate(form.email, form.password);

if (data) {
await updateSession(data);
}
if (error) {
return { error, code: status };
} else if (error) {
return { error };
}

throw redirect(form.redirect || "/");
Expand All @@ -32,11 +31,11 @@ export const unauthenticate = action(async () => {
export const resetPassword = action(async (body: components["schemas"]["UserPasswordReset"]) => {
"use server";

const { data } = await $resetPassword(body);
const { data, error } = await $resetPassword(body);

if (data) {
await updateSession(data);
return true;
} else if (error) {
return { error };
}
return false;
});
6 changes: 4 additions & 2 deletions frontend/src/lib/api/auth/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export const $authenticate = async (username: string, password: string) => {
},
bodySerializer: formDataSerializer,
});
return { data: result.data, status: result.response.status, error: result.error };

return { data: result.data, error: result.error };
};

export const $resetPassword = async (body: components["schemas"]["UserPasswordReset"]) => {
Expand All @@ -22,5 +23,6 @@ export const $resetPassword = async (body: components["schemas"]["UserPasswordRe
const result = await client.PATCH("/api/v1/auth/reset-password", {
body: body,
});
return { data: result.data };

return { data: result.data, error: result.error };
};
Loading

0 comments on commit ea22e27

Please sign in to comment.