Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions frontend/src/components/profile/EditProfileDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type { ChangeEvent, RefObject } from "react";
import { X } from "lucide-react";
import { profileActionClass as actionClass } from "./profileStyles";

type EditProfileDialogProps = {
dialogRef: RefObject<HTMLDialogElement | null>;
coverInputRef: RefObject<HTMLInputElement | null>;
profileInputRef: RefObject<HTMLInputElement | null>;
coverImage: string | null;
profileImage: string | null;
busy: boolean;
imageError?: string;
onClose: () => void;
onRemoveCoverImage: () => void;
onRemoveProfileImage: () => void;
onCoverImageChange: (event: ChangeEvent<HTMLInputElement>) => void;
onProfileImageChange: (event: ChangeEvent<HTMLInputElement>) => void;
};

export default function EditProfileDialog({
dialogRef,
coverInputRef,
profileInputRef,
coverImage,
profileImage,
busy,
imageError,
onClose,
onRemoveCoverImage,
onRemoveProfileImage,
onCoverImageChange,
onProfileImageChange,
}: EditProfileDialogProps) {
return (
<dialog
ref={dialogRef}
aria-labelledby="edit-profile-title"
onClose={onClose}
className="m-auto w-[calc(100%-2rem)] max-w-md rounded-2xl bg-white p-6 text-[#292624] shadow-xl backdrop:bg-black/40 dark:bg-[#242321] dark:text-[#f3f0ea]"
>
<div className="mb-6 flex items-center justify-between gap-4">
<h2 id="edit-profile-title" className="text-xl font-semibold">
Edit profile
</h2>
<button
type="button"
aria-label="Close edit profile"
onClick={() => dialogRef.current?.close()}
className={actionClass}
>
<X size={20} aria-hidden="true" />
</button>
</div>
{(["profile", "cover"] as const).map((type) => {
const image = type === "profile" ? profileImage : coverImage;
const input = type === "profile" ? profileInputRef : coverInputRef;
return (
<div
key={type}
className="flex items-center justify-between gap-3 border-t border-[#dedbd8] py-4 dark:border-[#504b46]"
>
<span className="text-sm font-medium">
{type === "profile" ? "Profile image" : "Cover image"}
</span>
<div className="flex items-center gap-1">
<button
type="button"
disabled={busy}
aria-label={`Upload ${type} image`}
onClick={() => input.current?.click()}
className={actionClass}
>
Change
</button>
{image && (
<button
type="button"
disabled={busy}
aria-label={`Remove ${type} image`}
onClick={
type === "profile"
? onRemoveProfileImage
: onRemoveCoverImage
}
className={`${actionClass} text-red-600 dark:text-red-400`}
>
Remove
</button>
)}
</div>
</div>
);
})}
{imageError && (
<p role="alert" className="mt-3 text-sm text-red-600 dark:text-red-400">
{imageError}
</p>
)}
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={() => dialogRef.current?.close()}
className={`${actionClass} border border-[#dedbd8] dark:border-[#504b46]`}
>
Done
</button>
</div>
<input
disabled={busy}
aria-label="Cover image file"
ref={coverInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={onCoverImageChange}
className="hidden"
/>
<input
disabled={busy}
aria-label="Profile image file"
ref={profileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={onProfileImageChange}
className="hidden"
/>
</dialog>
);
}
183 changes: 22 additions & 161 deletions frontend/src/components/profile/ProfileHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState, type ChangeEvent } from "react";
import { Ellipsis, LogOut, Pencil, UserRound, X } from "lucide-react";
import { useRef, type ChangeEvent } from "react";
import { Pencil, UserRound } from "lucide-react";
import EditProfileDialog from "./EditProfileDialog";
import ProfileOptionsMenu from "./ProfileOptionsMenu";

type ProfileHeaderProps = {
username: string;
Expand All @@ -16,9 +18,6 @@ type ProfileHeaderProps = {
onProfileImageChange: (event: ChangeEvent<HTMLInputElement>) => void;
};

const actionClass =
"cursor-pointer rounded-md px-3 py-2 text-sm text-[#514943] transition-colors hover:bg-[#f4f1ee] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#80766f] disabled:opacity-50 dark:text-[#e6dfd6] dark:hover:bg-[#302e2a]";

export default function ProfileHeader({
username,
coverColor,
Expand All @@ -36,21 +35,9 @@ export default function ProfileHeader({
const coverInputRef = useRef<HTMLInputElement>(null);
const profileInputRef = useRef<HTMLInputElement>(null);
const editDialogRef = useRef<HTMLDialogElement>(null);
const optionsRef = useRef<HTMLDivElement>(null);
const optionsButtonRef = useRef<HTMLButtonElement>(null);
const [optionsOpen, setOptionsOpen] = useState(false);
const busy = isUpdatingImages || isLoadingImages;

useEffect(() => {
if (!optionsOpen) return;
function dismiss(event: PointerEvent) {
if (!optionsRef.current?.contains(event.target as Node))
setOptionsOpen(false);
}
document.addEventListener("pointerdown", dismiss);
return () => document.removeEventListener("pointerdown", dismiss);
}, [optionsOpen]);

return (
<>
<div
Expand Down Expand Up @@ -117,154 +104,28 @@ export default function ProfileHeader({
<h1 className="min-w-0 wrap-anywhere text-center text-3xl font-bold sm:text-left sm:text-4xl">
{username}
</h1>
<div
ref={optionsRef}
className="relative shrink-0"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget))
setOptionsOpen(false);
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
setOptionsOpen(false);
optionsButtonRef.current?.focus();
}
}}
>
<button
ref={optionsButtonRef}
type="button"
aria-label="Profile options"
aria-expanded={optionsOpen}
aria-controls="profile-options"
onClick={() => setOptionsOpen(!optionsOpen)}
className={`${actionClass} grid size-10 place-items-center rounded-full p-0`}
>
<Ellipsis size={22} aria-hidden="true" />
</button>
{optionsOpen && (
<div
id="profile-options"
className="absolute right-0 top-12 z-30 w-44 rounded-xl border border-[#dedbd8] bg-white p-1.5 shadow-lg dark:border-[#504b46] dark:bg-[#242321]"
>
<button
type="button"
onClick={() => {
editDialogRef.current?.showModal();
setOptionsOpen(false);
}}
className={`${actionClass} flex w-full items-center gap-3 text-left`}
>
<Pencil size={16} aria-hidden="true" />
Edit profile
</button>
<button
type="button"
onClick={onLogout}
className={`${actionClass} flex w-full items-center gap-3 text-left`}
>
<LogOut size={16} aria-hidden="true" />
Log out
</button>
</div>
)}
</div>
<ProfileOptionsMenu
buttonRef={optionsButtonRef}
onEdit={() => editDialogRef.current?.showModal()}
onLogout={onLogout}
/>
</div>
</div>

<dialog
ref={editDialogRef}
aria-labelledby="edit-profile-title"
<EditProfileDialog
dialogRef={editDialogRef}
coverInputRef={coverInputRef}
profileInputRef={profileInputRef}
coverImage={coverImage}
profileImage={profileImage}
busy={busy}
imageError={imageError}
onClose={() => optionsButtonRef.current?.focus()}
className="m-auto w-[calc(100%-2rem)] max-w-md rounded-2xl bg-white p-6 text-[#292624] shadow-xl backdrop:bg-black/40 dark:bg-[#242321] dark:text-[#f3f0ea]"
>
<div className="mb-6 flex items-center justify-between gap-4">
<h2 id="edit-profile-title" className="text-xl font-semibold">
Edit profile
</h2>
<button
type="button"
aria-label="Close edit profile"
onClick={() => editDialogRef.current?.close()}
className={actionClass}
>
<X size={20} aria-hidden="true" />
</button>
</div>
{(["profile", "cover"] as const).map((type) => {
const image = type === "profile" ? profileImage : coverImage;
const input = type === "profile" ? profileInputRef : coverInputRef;
return (
<div
key={type}
className="flex items-center justify-between gap-3 border-t border-[#dedbd8] py-4 dark:border-[#504b46]"
>
<span className="text-sm font-medium">
{type === "profile" ? "Profile image" : "Cover image"}
</span>
<div className="flex items-center gap-1">
<button
type="button"
disabled={busy}
aria-label={`Upload ${type} image`}
onClick={() => input.current?.click()}
className={actionClass}
>
Change
</button>
{image && (
<button
type="button"
disabled={busy}
aria-label={`Remove ${type} image`}
onClick={
type === "profile"
? onRemoveProfileImage
: onRemoveCoverImage
}
className={`${actionClass} text-red-600 dark:text-red-400`}
>
Remove
</button>
)}
</div>
</div>
);
})}
{imageError && (
<p
role="alert"
className="mt-3 text-sm text-red-600 dark:text-red-400"
>
{imageError}
</p>
)}
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={() => editDialogRef.current?.close()}
className={`${actionClass} border border-[#dedbd8] dark:border-[#504b46]`}
>
Done
</button>
</div>
<input
disabled={busy}
ref={coverInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={onCoverImageChange}
className="hidden"
/>
<input
disabled={busy}
ref={profileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={onProfileImageChange}
className="hidden"
/>
</dialog>
onRemoveCoverImage={onRemoveCoverImage}
onRemoveProfileImage={onRemoveProfileImage}
onCoverImageChange={onCoverImageChange}
onProfileImageChange={onProfileImageChange}
/>
</>
);
}
Loading