feat : WEB-9 관리자 메인 페이지#22
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors the admin home page to reuse visitor sections (ActivitiesSection, TimelineSection, and RecruitSection) with inline editing capabilities powered by a new Zustand store (useHomeContentStore). The review feedback identifies several critical issues and improvement opportunities: a regression in TimelineSection where the expand/collapse detailed view was omitted, a bug preventing saves when the tech stack is empty, a limitation with saving partial project periods, a potential memory leak from unrevoked object URLs, a potential runtime error in highlightOne due to missing null checks, and opportunities to optimize state updates by batching recruitment content updates.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| function TimelineCard({ event, isRight, isEditing, formRef, onSaveEdit, onCancelEdit }) { | ||
| return ( | ||
| <div className={`relative bg-white rounded-2xl px-9 py-6 shadow-md ${isRight ? 'text-left' : 'text-right'}`}> | ||
| <span | ||
| aria-hidden="true" | ||
| className={`absolute top-6 h-3 w-3 rotate-45 bg-white ${isRight ? '-left-1.5' : '-right-1.5'}`} | ||
| /> | ||
|
|
||
| {isEditing ? ( | ||
| <TimelineEditForm ref={formRef} event={event} onSave={onSaveEdit} onCancel={onCancelEdit} /> | ||
| ) : ( | ||
| <div className={isRight ? '' : 'ml-auto'}> | ||
| <p className="text-sm text-brand font-bold m-0 mb-2"> | ||
| {event.year} | ||
| {event.projectName ? `, ${event.projectName}` : ''} | ||
| </p> | ||
| <p className="text-base font-bold text-ink m-0 mb-2">{event.award}</p> | ||
| <p className="text-xs text-[#AAAAAA] m-0">{event.activity}</p> | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
기존에 구현되어 있던 타임라인 카드의 상세 정보(기간, 인원, 기술 스택, 프로젝트 소개, 이미지 등)를 보여주는 펼치기/접기(Expand/Collapse) 기능과 상세 렌더링 로직이 완전히 누락되었습니다.
이로 인해 방문자 페이지에서 프로젝트의 상세 정보를 확인할 수 없는 심각한 기능 퇴화(Regression)가 발생합니다.
수정 모드가 아닐 때는 기존처럼 클릭 시 상세 내용이 펼쳐지도록 로컬 상태(isExpanded)를 추가하고 상세 렌더링 로직을 복구해야 합니다.
function TimelineCard({ event, isRight, isEditing, formRef, onSaveEdit, onCancelEdit }) {
const [isExpanded, setIsExpanded] = useState(false);
const handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsExpanded((prev) => !prev);
}
};
return (
<div
onClick={() => !isEditing && setIsExpanded((prev) => !prev)}
onKeyDown={handleKeyDown}
role={isEditing ? undefined : 'button'}
tabIndex={isEditing ? undefined : 0}
aria-expanded={isExpanded}
className={`relative bg-white rounded-2xl px-9 py-6 shadow-md transition-all duration-300 ${
isExpanded ? 'border-brand' : 'border-[#EDCFBC]'
} ${isRight ? 'text-left' : 'text-right'} ${isEditing ? '' : 'cursor-pointer select-none'}`}
>
<span
aria-hidden="true"
className={`absolute top-6 h-3 w-3 rotate-45 bg-white ${isRight ? '-left-1.5' : '-right-1.5'}`}
/>
{isEditing ? (
<TimelineEditForm ref={formRef} event={event} onSave={onSaveEdit} onCancel={onCancelEdit} />
) : (
<>
<div className={isRight ? '' : 'ml-auto'}>
<p className="text-sm text-brand font-bold m-0 mb-2">
{event.year}
{event.projectName ? `, ${event.projectName}` : ''}
</p>
<p className="text-base font-bold text-ink m-0 mb-2">{event.award}</p>
<p className="text-xs text-[#AAAAAA] m-0">{event.activity}</p>
</div>
<div
className={`overflow-hidden transition-all duration-400 ease-in-out ${
isExpanded ? 'max-h-[600px] opacity-100 mt-4' : 'max-h-0 opacity-0'
}`}
>
<hr className="border-[#EDCFBC] mb-4" />
<div className={`flex gap-6 mb-4 text-xs text-ink-sub ${isRight ? '' : 'justify-end'}`}>
<span className="flex items-center gap-1">
<span>📅</span> {event.period}
</span>
{event.memberCount && (
<span className="flex items-center gap-1">
<span>👥</span> {event.memberCount}명
</span>
)}
</div>
{event.techStack && event.techStack.length > 0 && (
<div className={`mb-4 ${isRight ? '' : 'flex flex-col items-end'}`}>
<p className="text-xs text-[#AAAAAA] m-0 mb-2">기술 스택</p>
<div className={`flex flex-wrap gap-2 ${isRight ? '' : 'justify-end'}`}>
{event.techStack.map((tech) => (
<span
key={tech}
className="px-3 py-1 rounded-full bg-brand-soft text-brand text-xs font-medium"
>
{tech}
</span>
))}
</div>
</div>
)}
<div className={`mb-4 ${isRight ? '' : 'text-right'}`}>
<p className="text-xs text-[#AAAAAA] m-0 mb-2">프로젝트 소개</p>
<p className="text-sm text-ink leading-6 m-0 whitespace-pre-line">{event.description}</p>
</div>
{event.images && event.images.length > 0 && (
<div className="grid grid-cols-3 gap-3 mt-2">
{event.images.map((src, index) => (
<img
key={index}
src={src}
alt={`${event.projectName} 이미지 ${index + 1}`}
className="w-full aspect-[4/3] object-cover rounded-lg bg-[#F0F0F0]"
/>
))}
</div>
)}
</div>
</>
)}
</div>
);
}| const validTechStack = techStack.map((tech) => tech.trim()).filter(Boolean); | ||
| if (validTechStack.length === 0) { | ||
| alert('기술 스택을 입력해주세요.'); | ||
| return; | ||
| } |
There was a problem hiding this comment.
techStack이 비어있는 이벤트(예: 기획 단계인 2026년도 기본 데이터)를 수정하려고 할 때, 기술 스택이 없으면 저장이 불가능한 버그가 있습니다.
EMPTY_EVENT나 2026년도 데이터처럼 기술 스택이 아직 정의되지 않은 상태에서도 저장이 가능하도록 필수 입력 검증 조건을 완화하거나 제거해야 합니다.
| const validTechStack = techStack.map((tech) => tech.trim()).filter(Boolean); | |
| if (validTechStack.length === 0) { | |
| alert('기술 스택을 입력해주세요.'); | |
| return; | |
| } | |
| const validTechStack = techStack.map((tech) => tech.trim()).filter(Boolean); |
| period: | ||
| periodStart && periodEnd | ||
| ? `${periodStart.replace('-', '.')} - ${periodEnd.replace('-', '.')}` | ||
| : '', |
There was a problem hiding this comment.
현재 코드에서는 periodStart와 periodEnd가 둘 다 존재할 때만 period가 저장되고, 하나라도 비어있으면 빈 문자열('')로 초기화됩니다.
진행 중인 프로젝트의 경우 종료일(periodEnd)을 비워둘 수 있으므로, 시작일만 있거나 종료일만 있는 경우에도 정상적으로 저장되도록 개선하는 것이 좋습니다.
| period: | |
| periodStart && periodEnd | |
| ? `${periodStart.replace('-', '.')} - ${periodEnd.replace('-', '.')}` | |
| : '', | |
| period: [periodStart, periodEnd] | |
| .filter(Boolean) | |
| .map((p) => p.replace('-', '.')) | |
| .join(' - '), |
| const handleRemoveImage = (index) => { | ||
| setImages((previous) => previous.filter((_, i) => i !== index)); | ||
| }; |
There was a problem hiding this comment.
URL.createObjectURL로 생성된 객체 URL은 브라우저 메모리에 계속 유지되므로, 이미지를 삭제할 때 URL.revokeObjectURL을 호출하여 메모리 누수를 방지하는 것이 좋습니다.
const handleRemoveImage = (index) => {
const targetImage = images[index];
if (targetImage && targetImage.startsWith('blob:')) {
URL.revokeObjectURL(targetImage);
}
setImages((previous) => previous.filter((_, i) => i !== index));
};| function highlightOne(text) { | ||
| return text.split('\n').map((line, lineIndex) => ( |
| updateRecruitHeading: (heading) => set({ recruitHeading: heading }), | ||
| updateRecruitInfoItem: (index, value) => | ||
| set((state) => ({ | ||
| recruitInfoList: state.recruitInfoList.map((item, i) => (i === index ? { ...item, value } : item)), | ||
| })), |
There was a problem hiding this comment.
현재 RecruitSection에서 저장 시 updateRecruitHeading과 updateRecruitInfoItem을 루프 내에서 각각 호출하고 있어 불필요한 다중 상태 업데이트(State Update)와 리렌더링이 발생합니다.
이를 개선하기 위해 제목과 정보 리스트를 한 번에 업데이트하는 단일 액션(updateRecruitContent)을 정의하여 성능을 최적화하는 것을 권장합니다.
updateRecruitContent: (heading, values) =>
set((state) => ({
recruitHeading: heading,
recruitInfoList: state.recruitInfoList.map((item, i) => ({
...item,
value: values[i] !== undefined ? values[i] : item.value,
})),
})),| const updateRecruitHeading = useHomeContentStore((state) => state.updateRecruitHeading); | ||
| const updateRecruitInfoItem = useHomeContentStore((state) => state.updateRecruitInfoItem); | ||
|
|
||
| const [isEditing, setIsEditing] = useState(false); | ||
|
|
||
| const handleSave = (heading, values) => { | ||
| updateRecruitHeading(heading); | ||
| values.forEach((value, index) => updateRecruitInfoItem(index, value)); | ||
| setIsEditing(false); | ||
| }; |
There was a problem hiding this comment.
homeContentStore에 제안된 단일 업데이트 액션(updateRecruitContent)을 사용하여 불필요한 다중 렌더링을 방지하고 코드를 간결하게 유지합니다.
const updateRecruitContent = useHomeContentStore((state) => state.updateRecruitContent);
const [isEditing, setIsEditing] = useState(false);
const handleSave = (heading, values) => {
updateRecruitContent(heading, values);
setIsEditing(false);
};


📌 작업 내용
관리자 메인 페이지 퍼블리싱