Skip to content
1 change: 1 addition & 0 deletions src/components/Dropdown/Dropdown.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
border-radius: 8px;
margin-top: 8px;
box-shadow: 0px 2px 12px 0px #00000014;
z-index: 100;
}

&__list-item {
Expand Down
4 changes: 4 additions & 0 deletions src/components/Editor/Editor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,14 @@ function OnEditorChange({ onUpdate }) {
* @param {boolean} [readOnly=false] - 읽기 전용 모드 여부
*/
export default function Editor({
inputId = '',
content = '',
onUpdate = () => {},
style = {},
readOnly = false,
font = 'Pretendard',
className = '',
onBlur,
}) {
// 1) theme: 텍스트 포맷 → CSS 클래스 매핑
const theme = {
Expand Down Expand Up @@ -102,6 +104,8 @@ export default function Editor({
<ContentEditable
style={{ fontFamily: getFontFamily(font) }}
className={styles.editor__content}
id={inputId}
onBlur={onBlur} // 블러 이벤트 핸들러
/>
}
placeholder={
Expand Down
6 changes: 6 additions & 0 deletions src/components/Editor/Editor.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

.editor__content {
/* 에디터 입력 영역(콘텐츠Editable) 스타일 */
position: relative;
min-height: 260px;
padding: 12px;
outline: none;
font-family: inherit !important; /* 부모에서 상속받은 폰트 사용 */
font-size: var(--font-size-16);
color: var(--color-gray-900);
max-height: 400px;
overflow-y: auto;

/* 롤링페이퍼 폰트는 부모에서 전달받음 */
/* 혹시 모를 리셋(reset.css) 덮어쓰기 방지 */
Expand All @@ -21,6 +24,9 @@
}

.editor__placeholder {
position: absolute;
top: 50px;
left: 5px;
/* 내용이 비어 있을 때 보여줄 플레이스홀더 문구 스타일 */
color: var(--color-gray-400);
padding: 12px;
Expand Down
2 changes: 1 addition & 1 deletion src/components/PostHeader/EmojiGroup/EmojiAdd.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export default function EmojiAdd({ id, onSuccess, isMobile = false }) {
* @todo IconButton 컴포넌트로 변경 예정
*/
const toggleButton = isMobile ? (
<Button icon={AddImojiIcon} enabled={!loading} variant='outlined' size='36' iconOnly={true} />
<Button icon={AddImojiIcon} enabled={!loading} variant='outlined' size='28' iconOnly={true} />
) : (
<Button
icon={AddImojiIcon}
Expand Down
5 changes: 5 additions & 0 deletions src/components/PostHeader/EmojiGroup/EmojiGroup.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
display: flex;
align-items: center;
gap: 8px;

@include mobile {
width: 100%;
justify-content: space-between;
}
}

/* 토글 버튼 래퍼 */
Expand Down
11 changes: 6 additions & 5 deletions src/components/PostHeader/EmojiGroup/ToggleEmoji.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
display: flex;
align-items: center;
gap: 8px; /* 각 뱃지 간격 */
@include mobile {
justify-content: start;
}
width: 270px;
justify-content: end;
@include mobile {
justify-content: start;
width: 236px;
}
width: 270px;
justify-content: end;

&__button-icon {
transition: transform 0.3s ease;
Expand Down
19 changes: 15 additions & 4 deletions src/components/PostHeader/PostHeader.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,25 @@
gap: 28px;
}

&__emoji-container {
&__reaction-container {
display: flex;
align-items: center;
gap: 13px;

@include mobile {
width: 100%; // 모바일에서는 전체 너비 사용
gap: 8px; // 아이템 간격 축소
}
}
&__reaction-container {

&__emoji-container {
display: flex;
align-items: center;
gap: 13px;

@include mobile {
width: 100%; // 모바일에서는 전체 너비 사용
justify-content: space-between; // 아이템 간격 조정
}
}

&__divider {
Expand Down Expand Up @@ -72,7 +83,7 @@
&__menu-container {
width: 100%; // 가로 전체 너비
height: 52px;
justify-content: start; // 중앙 정렬
justify-content: space-between; // 중앙 정렬
margin: 0 auto; // 중앙 정렬
gap: 12px; // 아이템 간격 축소
}
Expand Down
8 changes: 7 additions & 1 deletion src/components/PostHeader/ShareMenu/ShareMenu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import Style from './ShareMenu.module.scss';
import { useCallback } from 'react';
import { useToast } from '@/hooks/useToast';
import Button from '@/components/Button/Button';
import { useDeviceType } from '@/hooks/useDeviceType';
import { DEVICE_TYPES } from '@/constants/deviceType';
/**
*
* 공유 아이콘(버튼)을 클릭했을 때 아래 두 가지 메뉴가 표시됩니다.
Expand All @@ -18,6 +20,8 @@ import Button from '@/components/Button/Button';
*/
export default function ShareMenu({ onKakaoClick }) {
const { showToast } = useToast();
const deviceType = useDeviceType();
const isMobile = deviceType === DEVICE_TYPES.PHONE;
const handleUrlCopy = useCallback(async () => {
try {
const currentUrl = window.location.href;
Expand All @@ -40,7 +44,9 @@ export default function ShareMenu({ onKakaoClick }) {
}, [showToast]);

// 토글 버튼(Share 아이콘)
const toggleButton = (
const toggleButton = isMobile ? (
<Button icon={ShareIcon} iconOnly variant='outlined' size='28' aria-label='공유하기' />
) : (
<Button icon={ShareIcon} iconOnly variant='outlined' size='36' aria-label='공유하기' />
);

Expand Down
10 changes: 6 additions & 4 deletions src/components/Textfield.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
font-size: var(--font-size-16);
font-weight: var(--font-weight-regular);
color: var(--color-gray-500);
transition: border-color 0.15s ease;

&:hover {
border: 1px solid var(--color-purple-600);
Expand All @@ -16,7 +17,7 @@

&:focus,
&:active {
border: 2px solid var(--color-purple-600);
border: 1px solid var(--color-purple-600);
color: var(--color-gray-900);
}

Expand All @@ -37,7 +38,7 @@

&:focus,
&:hover {
border: 1px solid var(--color-error);
border-color: var(--color-error);
}
}

Expand All @@ -51,15 +52,16 @@

&:focus,
&:hover {
border: 1px solid var(--color-green-500);
border-color: var(--color-green-500);
}
}
}

.textfield__message {
bottom: 0;
font-size: var(--font-size-12);
margin-top: 4px;
margin-left: 10px;
margin-left: 10px;
&--error {
color: var(--color-error);
}
Expand Down
134 changes: 78 additions & 56 deletions src/hooks/useForm.jsx
Original file line number Diff line number Diff line change
@@ -1,82 +1,104 @@
// src/hooks/useForm.jsx
import { useState, useCallback, useMemo } from 'react';
import { useState } from 'react';

/**
* 폼 상태와 검증을 관리하는 커스텀 훅
* 범용 폼 훅 – 모든 검증 규칙은 호출 측에서 주입
*
* @param {Object} initialValues
* @param {Object.<string,(value:any)=>boolean>} [customValidationRules]
* @template V
* @param {V} initialValues 초기 필드 값
* @param {Object.<keyof V, Array<{test:(value:any, all:V)=>boolean, message:string}>>} validationRules
* 필드별 검증 규칙(배열). 규칙이 없으면 해당 필드는 항상 통과
*
* @returns {{
* values: Object,
* validity: Object, // 필드별 유효성
* touched: Object, // 필드별 입력 여부
* handleChange: (field:string)=>(v:any)=>void,
* handleBlur: (field:string)=>() => void, // blur 전용
* resetForm: () => void,
* isFormValid: boolean
* values: V,
* errorMessages: Record<keyof V, string|null>,
* fieldValidity: Record<keyof V, boolean|null>, // null=미검증, true=통과, false=실패
* handleChange: (field:keyof V)=>(value:any)=>void,
* handleBlur: (field:keyof V)=>() => void,
* resetForm: () => void,
* isFormValid: boolean
* }}
*/

export const useForm = (initialValues = {}, customValidationRules = {}) => {
/* 1) 값 관리 */
const [values, setValues] = useState(initialValues);
export const useForm = (initialValues = {}, validationRules = {}) => {
const fieldNames = Object.keys(initialValues);

/* 2) 초기 유효성 계산 */
const initValidity = useMemo(() => {
const validate = (k, v) =>
typeof customValidationRules[k] === 'function'
? customValidationRules[k](v)
: typeof v === 'string'
? v.trim() !== ''
: v != null;
// 다른 필드 값(allValue)들을 포함한 유효성 검사 -> 첫 번째 실패 메시지 또는 null
// 다른 필드의 값이 필요할 때는 allValues를 통해 접근
const validateField = (fieldName, value, allValues) => {
const rulesForField = validationRules[fieldName] ?? [];
const failedRule = rulesForField.find((rule) => !rule.test(value, allValues));
return failedRule ? failedRule.message : null;
};
/* ------------------------------- 상태 관리 ------------------------------- */
const [values, setValues] = useState(initialValues);

return Object.fromEntries(Object.entries(initialValues).map(([k, v]) => [k, validate(k, v)]));
}, [initialValues, customValidationRules]);
const [validity, setValidity] = useState(initValidity);
// 초기값으로 각 필드의 유효성 검사 결과를 설정
const [errorMessages, setErrorMessages] = useState(() => {
const initialMessage = {};
for (const field in initialValues) {
initialMessage[field] = validateField(field, initialValues[field], initialValues);
}
return initialMessage;
});

/* 3) touched 상태 */
const [touched, setTouched] = useState(
Object.fromEntries(Object.keys(initialValues).map((k) => [k, false])),
// 한번이라도 입력된 필드인지 여부
// 초기값으로 필드별 touched 상태를 false로 설정
const [touchedFields, setTouchedFields] = useState(() =>
Object.fromEntries(fieldNames.map((name) => [name, false])),
);

/* 4) 값 & 유효성 & touched 업데이트 */
const handleChange = useCallback(
(field) => (newValue) => {
setValues((p) => ({ ...p, [field]: newValue }));
setTouched((p) => ({ ...p, [field]: true }));

const isValid =
typeof customValidationRules[field] === 'function'
? customValidationRules[field](newValue)
: typeof newValue === 'string'
? newValue.trim() !== ''
: newValue != null;
// 필드가 아직 건드리지 않았으면 null, 통과하면 true, 에러면 false
const getFieldValidity = (fieldName) => {
if (!touchedFields[fieldName]) {
return null; // 아직 건드리지 않음
}
if (errorMessages[fieldName] === null) {
return true; // 통과
}
return false; // 에러
};

setValidity((p) => ({ ...p, [field]: isValid }));
},
[customValidationRules],
// 각 필드의 유효성 검사 결과를 객체로 반환
const fieldValidity = Object.fromEntries(
fieldNames.map((name) => [name, getFieldValidity(name)]),
);

/* blur 만으로 touched 표시하고 싶을 때 */
const handleBlur = useCallback((field) => () => setTouched((p) => ({ ...p, [field]: true })), []);
// 전체 폼이 유효한지 여부
const isFormValid = fieldNames.every((name) => errorMessages[name] === null);

/* 5) 초기화 */
const resetForm = useCallback(() => {
setValues(initialValues);
setValidity(initValidity);
setTouched(Object.fromEntries(Object.keys(initialValues).map((k) => [k, false])));
}, [initialValues, initValidity]);
/* -------------------------------------------- handlers */
const handleChange = (fieldName) => (newValue) => {
setValues((previous) => {
const next = { ...previous, [fieldName]: newValue };
setErrorMessages((previousErrors) => ({
...previousErrors,
[fieldName]: validateField(fieldName, newValue, next),
}));
return next;
});
};

const handleBlur = (fieldName) => () =>
setTouchedFields((prev) => ({ ...prev, [fieldName]: true }));

/* 6) 폼 전체 유효성 */
const isFormValid = useMemo(() => Object.values(validity).every(Boolean), [validity]);
const resetForm = () => {
setValues(initialValues);
setTouchedFields(Object.fromEntries(fieldNames.map((name) => [name, false])));
setErrorMessages(() =>
Object.fromEntries(
fieldNames.map((name) => [name, validateField(name, initialValues[name], initialValues)]),
),
);
};

/* ------------------------------------------------ return */
return {
values,
validity, // ➕
touched, // ➕
errorMessages,
fieldValidity,
handleChange,
handleBlur, // ➕
handleBlur,
resetForm,
isFormValid,
};
Expand Down
Loading
Loading