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
46 changes: 46 additions & 0 deletions src/components/CountUp.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// src/components/CountUp/CountUp.jsx
import { useEffect, useState } from 'react';

/**
* 숫자 카운트 애니메이션
*
* @param {object} props
* @param {number} props.start - 시작 값
* @param {number} props.end - 종료 값
* @param {number} props.duration - 애니메이션 지속시간(ms)
* @param {string} [props.className]
* @param {function} [props.format] - 값 포맷터 (예: (v)=>v.toLocaleString())
*/
export default function CountUp({
start = 0,
end = 100,
duration = 1000,
className = '',
format = (v) => v,
}) {
const [value, setValue] = useState(start);
useEffect(() => {
const delta = end - start;
if (delta === 0 || duration === 0) {
setValue(end);
return;
}
// 애니메이션 단계 계산
const steps = Math.abs(delta);
const intervalMs = duration / steps;

let current = start;
// setInterval을 사용하여 단계별로 값 업데이트
// delta > 0: 증가, delta < 0: 감소
const id = setInterval(() => {
current += delta > 0 ? 1 : -1;
setValue(current);

if (current === end) clearInterval(id);
}, intervalMs);
// 컴포넌트 언마운트 시 interval 정리
return () => clearInterval(id);
}, [start, end, duration]);

return <span className={className}>{format(Math.round(value))}</span>;
}
154 changes: 98 additions & 56 deletions src/components/DropdownButton/DropdownButton.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { useDropdownPosition } from '@/hooks/useDropdownPosition';
import styles from './DropdownButton.module.scss';
import classnames from 'classnames';

/**
* DropdownButton 컴포넌트
Expand All @@ -19,8 +20,12 @@ import styles from './DropdownButton.module.scss';
* - 메뉴 컨테이너 영역에 추가할 클래스 이름
* @param {function} [props.onToggle]
* - 열기/닫기 상태 변화 시 호출되는 콜백 (인자로 (isOpen: boolean))
* @param {boolean} [props.openOnHover=false]
* - 토글 위에 마우스가 올라갈 때 드롭다운이 열리도록 할지 여부
* @param {trigger} [props.trigger='click']
* - 드롭다운 열기/닫기 트리거 방식 ('click' 또는 'hover', 'always')
* @param {number} [props.offset=4]
* - 드롭다운 메뉴 위치가 보일 간격 (px 단위)
* @param {number} [props.animationDuration=200]
* - 드롭다운 애니메이션 지속 시간 (ms 단위)
*/
function DropdownButton({
ToggleComponent,
Expand All @@ -29,73 +34,103 @@ function DropdownButton({
ButtonClassName = '',
MenuClassName = '',
onToggle,
openOnHover = false,
trigger = 'click',
offset = 4,
animationDuration = 200,
}) {
const [isOpen, setIsOpen] = useState(false);
const [isOpen, setIsOpen] = useState(false); // 드롭다운 열림 상태
const [locked, setLocked] = useState(false); // 클릭 고정인지 여부

// 밖을 클릭했을 때 닫기 위한 ref
const containerRef = useRef(null);
const menuRef = useRef(null);
// 드롭다운 위치 보정 훅
//커스텀 훅 호출: isOpen이 true일 때마다 위치 보정값(adjustX)을 계산
const adjustXValue = useDropdownPosition(containerRef, menuRef, isOpen);

const openDropdown = () => {
/* ---------- 열고/닫기 헬퍼 ---------- */
const open = () => {
setIsOpen(true);

onToggle && onToggle(true);
onToggle?.(true);
};

const closeDropdown = () => {
const close = () => {
setIsOpen(false);

onToggle && onToggle(false);
onToggle?.(false);
};

/* ---------- 토글 처리 ---------- */
const handleToggleClick = () => {
setIsOpen((prev) => {
const next = !prev;
onToggle && onToggle(next);
return next;
});
};
/* ───────── 1) hover 모드 : 클릭 무시 ───────── */
if (trigger === 'hover') {
return; // 아무 동작 없음
}

// 외부 클릭 시 닫기
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setIsOpen(false);
onToggle && onToggle(false);
/* ───────── 2) click 모드 : 단순 토글 ───────── */
if (trigger === 'click') {
if (isOpen) {
close(); // 이미 열려 있으면 닫기
} else if (!isOpen) {
open(); // 닫혀 있으면 열기
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, onToggle]);
return;
}

// 호버 동작: openOnHover가 true일 때만 처리
const handleMouseEnter = () => {
if (openOnHover) {
openDropdown();
/* ───────── 3) always 모드 ───────── */
if (trigger === 'always') {
/* 3-1. 닫혀 있으면 : 무조건 열고 lock 해제 */
if (!isOpen) {
setLocked(false);
open();
return;
}

/* 3-2. 열려 있고 lock이 해제되어 있으면 : lock 활성화 */
if (isOpen && !locked) {
setLocked(true); // 고정
return;
}

/* 3-3. 열려 있고 lock이 걸려 있으면 : lock 해제 + 닫기 */
if (isOpen && locked) {
setLocked(false);
close();
return;
}
}
};

/* ---------- hover 처리 ---------- */
const handleMouseEnter = () => {
if (trigger === 'hover' || (trigger === 'always' && !locked)) open();
};
const handleMouseLeave = () => {
if (openOnHover) {
closeDropdown();
}
if (trigger === 'hover' || (trigger === 'always' && !locked)) close();
};

const wrapperClass = [
styles.dropdown,
styles[`dropdown--${layout}`],
isOpen ? styles['dropdown--open'] : '',
]
.filter(Boolean)
.join(' ');
/* ---------- 외부 클릭 시 닫기 ---------- */
useEffect(() => {
if (!isOpen || (trigger === 'always' && locked)) return;

const handleOutside = (e) => {
if (!containerRef.current?.contains(e.target)) {
setIsOpen(false);
onToggle?.(false);
}
};
document.addEventListener('mousedown', handleOutside);
return () => document.removeEventListener('mousedown', handleOutside);
}, [isOpen, locked, trigger, onToggle]);

const toggleClass = [styles['dropdown__toggle'], ButtonClassName].filter(Boolean).join(' ');
const menuClass = [styles['dropdown__menu'], MenuClassName].filter(Boolean).join(' ');
/* ---------- 애니메이션 시간 계산 ---------- */
const duration =
typeof animationDuration === 'number'
? { open: animationDuration, close: animationDuration }
: { open: animationDuration.open, close: animationDuration.close };
/* ---------- 클래스 이름 설정 ---------- */
const wrapperClass = classnames(styles.dropdown, styles[`dropdown--${layout}`], {
[styles['dropdown--open']]: isOpen,
});
const toggleClass = classnames(styles['dropdown__toggle'], ButtonClassName);
const menuClass = classnames(styles['dropdown__menu'], MenuClassName);

return (
<div
Expand All @@ -107,17 +142,24 @@ function DropdownButton({
<div className={toggleClass} onClick={handleToggleClick}>
{ToggleComponent}
</div>
<div
ref={menuRef}
className={menuClass}
style={{
transform: isOpen
? `scaleY(1) translateX(calc(-50% + ${adjustXValue}px))`
: `scaleY(0) translateX(calc(-50% + ${adjustXValue}px))`,
}}
>
{ListComponent}
</div>
{/* 드롭다운 메뉴: null 또는 undefined 경우 표시 안함 */}
{ListComponent && (
<div
ref={menuRef}
className={menuClass}
style={{
marginTop: `${offset}px`,
transition: `transform ${duration.open}ms ease, opacity ${duration.open}ms ease`,
transform: isOpen
? `scaleY(1) translateX(calc(-50% + ${adjustXValue}px))`
: `scaleY(0) translateX(calc(-50% + ${adjustXValue}px))`,
opacity: isOpen ? 1 : 0,
pointerEvents: isOpen ? 'auto' : 'none',
}}
>
{ListComponent}
</div>
)}
</div>
);
}
Expand Down
11 changes: 1 addition & 10 deletions src/components/DropdownButton/DropdownButton.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -46,22 +46,13 @@
position: absolute;
top: 100%; /* 토글 바로 아래 */
left: 50%; /* 토글의 중앙 기준으로 정렬 */
margin-top: 4px;

padding: 8px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
background-color: var(--color-white);

/* 중앙 정렬을 위해 X축으로 -50% 이동 */
transform-origin: top center;
transform: scaleY(0) translateX(-50%);
opacity: 0;
pointer-events: none;

/* 열림/닫힘 애니메이션 */
transition:
transform 100ms ease,
opacity 100ms ease;
z-index: 100;
}

Expand Down
3 changes: 3 additions & 0 deletions src/components/GradientImage/GradientImage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ import styles from './GradientImage.module.scss';
* @param {string} props.src 실제 이미지 URL
* @param {string} [props.alt] 대체 텍스트
* @param {string} [props.className] 추가 클래스
* @param {Object} [props.style] 추가 스타일
* @param {Function} [props.onLoaded] 이미지 로딩 완료 시 호출될 콜백
* @param {Object} [props.rest] 기타 <img> 속성
*/
export default function GradientImage({
src,
alt = '',
className = '',
style = {},
onClick,
onLoaded,
...rest
Expand All @@ -37,6 +39,7 @@ export default function GradientImage({
className={classNames(className, styles['gradient-image'], {
[styles['gradient-image--loaded']]: loaded,
})}
style={style}
onClick={onClick}
>
{src && (
Expand Down
9 changes: 5 additions & 4 deletions src/components/Header/GlobalHeader.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ function GlobalHeader() {
const VISIBLE_PATHS = ['/', '/list'];
const showButton = useShowComponent(VISIBLE_PATHS);
const isMobile = deviceType === DEVICE_TYPES.PHONE;
if (isMobile) {
// 모바일에서는 버튼을 숨김
return null;
}

const handleButtonClick = () => {
navigate('/post');
};

if (isMobile && !showButton) {
// 모바일이면서 버튼을 보여주지 않는 경우 헤더를 숨김
return null;
}
return (
<header className={Style['header']}>
<div className={Style['header__container']}>
Expand Down
3 changes: 3 additions & 0 deletions src/components/Header/GlobalHeader.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
display: flex;
justify-content: center;
background-color: var(--color-white);
position: sticky;
top: 0; // 뷰포트 최상단에 붙임
z-index: 1000;

&__container {
display: flex;
Expand Down
2 changes: 1 addition & 1 deletion src/components/LoadingLabel/LoadingLabel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import styles from './LoadingLabel.module.scss';
export default function LoadingLabel({
loading,
loadingText = '로딩 중...',
loadedText = '완료',
loadedText = '',
className = '',
}) {
return (
Expand Down
Loading
Loading