Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
4 changes: 4 additions & 0 deletions src/assets/icon/common/camera.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/icon/common/lock.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/icon/common/userProfileCircle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
110 changes: 110 additions & 0 deletions src/components/setting/PasswordSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useState } from "react";

import Input from "../common/input/Input";

import EyeIcon from "@/assets/icon/common/eye.svg?react";
import EyeOffIcon from "@/assets/icon/common/eye-off.svg?react";
import LockIcon from "@/assets/icon/common/lock.svg?react";

type TPasswordSectionProps = {
currentPassword: string;
setCurrentPassword: (v: string) => void;
newPassword: string;
setNewPassword: (v: string) => void;
confirmNewPassword: string;
setConfirmNewPassword: (v: string) => void;
errors: {
currentPassword: string;
newPassword: string;
confirmNewPassword: string;
};
};

export default function PasswordSection({
currentPassword,
setCurrentPassword,
newPassword,
setNewPassword,
confirmNewPassword,
setConfirmNewPassword,
errors,
}: TPasswordSectionProps) {
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);

return (
<div className="bg-white border border-gray-100 rounded-component-lg p-8 shadow-Soft">
<header className="mb-7 flex items-start justify-between gap-4">
<div>
<div className="flex gap-4 items-center">
<LockIcon />
<h2 className="font-heading4 text-text-main font-semibold!">
비밀번호 변경
</h2>
</div>

<p className="font-body2 text-text-sub mt-2">
안전한 계정 사용을 위해 주기적인 비밀번호 변경을 권장합니다
<br /> 영문, 숫자, 특수문자가 모두 포함된 8~16자리의 비밀번호를
설정해 주세요
</p>
</div>
</header>
<div className="grid grid-cols-1 tablet:grid-cols-1 gap-x-8 gap-y-6 tablet:max-w-4xl">
<div className="tablet:col-span-1">
<Input
type={showCurrent ? "text" : "password"}
label="현재 비밀번호"
placeholder="기존 비밀번호를 입력하세요"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
error={!!errors.currentPassword}
helperText={errors.currentPassword}
rightElement={
<button type="button" onClick={() => setShowCurrent((p) => !p)}>
{showCurrent ? <EyeOffIcon /> : <EyeIcon />}
</button>
}
/>
</div>

<Input
type={showNew ? "text" : "password"}
label="새 비밀번호"
placeholder="8~16자 영문, 숫자, 특수문자"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
error={!!errors.newPassword}
helperText={errors.newPassword}
rightElement={
<button
type="button"
aria-label={
showCurrent ? "현재 비밀번호 숨기기" : "현재 비밀번호 보기"
}
aria-pressed={showCurrent}
onClick={() => setShowNew((p) => !p)}
>
{showNew ? <EyeOffIcon /> : <EyeIcon />}
</button>
}
/>
<Input
type={showConfirm ? "text" : "password"}
label="새 비밀번호 확인"
placeholder="한 번 더 입력하세요"
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
error={!!errors.confirmNewPassword}
helperText={errors.confirmNewPassword}
rightElement={
<button type="button" onClick={() => setShowConfirm((p) => !p)}>
{showConfirm ? <EyeOffIcon /> : <EyeIcon />}
</button>
}
Comment thread
jjjsun marked this conversation as resolved.
Resolved
/>
</div>
</div>
);
}
168 changes: 168 additions & 0 deletions src/components/setting/ProfileSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import type React from "react";

import Button from "../common/button/Button";
import Input from "../common/input/Input";

import CameraIcon from "@/assets/icon/common/camera.svg?react";
import CheckIcon from "@/assets/icon/common/check.svg?react";
import UserProfileCircleIcon from "@/assets/icon/common/userProfileCircle.svg?react";

type TProfileSectionProps = {
name: string;
setName: (v: string) => void;
organizations: { name: string; position: string }[];
email: string;
phoneNumber: string;
fileRef: React.RefObject<HTMLInputElement | null>;
preview: string | null;
openFilePicker: () => void;
onPickFile: (e: React.ChangeEvent<HTMLInputElement>) => void;
resetImage: () => void;
};
export default function ProfileSection({
name,
setName,
organizations,
email,
phoneNumber,
fileRef,
preview,
onPickFile,
openFilePicker,
resetImage,
}: TProfileSectionProps) {
return (
<div className="bg-white border border-gray-100 rounded-component-lg p-8 shadow-Soft">
<header className="mb-7 flex items-start justify-between gap-4">
<div className="flex gap-4 items-center">
<UserProfileCircleIcon />
<h2 className="font-heading4 text-text-main font-semibold!">
프로필
</h2>
</div>
</header>
<div className="flex tablet:flex-row gap-10">
<div className="flex flex-col items-center basis-1/4 shrink-0">
<div className="w-full text-text-main mb-4 select-none">
프로필 이미지
</div>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/jpg,image/png,image/webp"
className="hidden"
onChange={onPickFile}
/>
<div className="relative h-60 w-60 overflow-hidden rounded-full border border-gray-100 bg-gray-100 shadow-sm flex items-center justify-center">
{preview ? (
<img
src={preview}
alt="프로필 이미지 미리보기"
className="w-full h-full object-cover"
/>
) : (
<CameraIcon className="text-text-disabled w-10 h-10" />
)}
</div>
<div className="mt-5 flex gap-3">
<Button
variant="custom"
type="button"
onClick={openFilePicker}
className="h-7! border border-gray-200 text-text-auth-sub px-4 rounded-component-lg bg-white font-body2 hover:bg-gray-100 transition-colors duration-200 ease-in-out"
aria-label="프로필 이미지 변경 버튼"
>
변경
</Button>
<Button
variant="custom"
type="button"
onClick={resetImage}
className="h-7! border border-gray-200 text-text-auth-sub px-4 rounded-component-lg bg-white font-body2 hover:bg-gray-100 transition-colors duration-200 ease-in-out disabled:cursor-not-allowed disabled:opacity-40"
aria-label="프로필 이미지 초기화 버튼"
disabled={!preview}
>
초기화
</Button>
</div>
</div>
<div className="grid grid-cols-1 tablet:grid-cols-2 gap-x-6 basis-3/4 gap-y-5 w-full">
<div className="col-span-2">
<Input
label="이름"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="col-span-2">
<div className="text-text-main mb-2 ml-1">소속 조직</div>
{organizations.length === 0 ? (
<div className="text-text-disabled font-body2 ml-1">
소속된 조직이 없습니다.
</div>
) : (
<>
<div className="flex gap-4 px-1 mb-1 text-text-sub">
<div className="flex-1 font-body2">조직</div>
<div className="flex-1 font-body2">직책</div>
</div>
<div className="group relative flex flex-col gap-4">
{organizations.map((org, idx) => (
<div key={idx} className="flex gap-4 ">
<div className="flex-1">
<Input
value={org.name}
disabled
inputClassName="text-text-main"
containerClassName="bg-gray-100"
readOnly
/>
</div>
<div className="flex-1">
<Input
value={org.position}
disabled
inputClassName="text-text-sub"
containerClassName="bg-gray-100"
readOnly
/>
</div>
</div>
))}

<div className="pointer-events-none absolute top-full mt-1 whitespace-nowrap rounded bg-gray-800 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100">
조직 정보는 별도 조직페이지에서 수정할 수 있습니다.
</div>
</div>
</>
)}
</div>
<div className="col-span-2 group relative w-full">
<Input
label="이메일"
value={email}
disabled={true}
rightElement={<CheckIcon className="w-6 h-6 text-chart-3" />}
readOnly
/>
<div className="pointer-events-none absolute top-full mt-1 whitespace-nowrap rounded bg-gray-800 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100">
이메일은 변경할 수 없습니다.
</div>
</div>
<div className="col-span-2 group relative w-full">
<Input
label="전화번호"
value={phoneNumber}
disabled={true}
rightElement={<CheckIcon className="w-6 h-6 text-chart-3" />}
readOnly
/>
<div className="pointer-events-none absolute top-full mt-1 whitespace-nowrap rounded bg-gray-800 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100">
전화번호는 변경할 수 없습니다.
</div>
</div>
</div>
</div>
</div>
);
}
1 change: 0 additions & 1 deletion src/components/workspace/PermissionTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ export default function PermissionTable() {
if (!hasChanges || isSaving) return;
setIsSaving(true);
try {
// TODO: 권한 저장 API 호출
await new Promise((resolve) => setTimeout(resolve, 500));
setSavedPermissionState(draftPermissionState);
toast.success("권한 설정이 저장되었습니다");
Expand Down
34 changes: 34 additions & 0 deletions src/hooks/common/useImageUploader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { ChangeEvent } from "react";
import { useEffect, useRef, useState } from "react";

export function useImageUploader() {
const fileRef = useRef<HTMLInputElement | null>(null);
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string | null>(null);

const openFilePicker = () => {
fileRef.current?.click();
};
const onPickFile = (e: ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;

setFile(f);

const url = URL.createObjectURL(f);
setPreview(url);
};
const resetImage = () => {
if (fileRef.current) {
fileRef.current.value = "";
}
setFile(null);
setPreview(null);
};
Comment thread
jjjsun marked this conversation as resolved.
Resolved
Comment thread
jjjsun marked this conversation as resolved.
Resolved
useEffect(() => {
return () => {
if (preview) URL.revokeObjectURL(preview);
};
});
return { fileRef, file, preview, openFilePicker, onPickFile, resetImage };
}
Loading
Loading