- {/* 메인 카피 */}
-
- 단순히 배우는 것을 넘어,
-
- 함께 성장할 ONE의 새로운 부원을 모집합니다.
-
-
- {/* 모집 정보 – 카드 없이 텍스트 나열 */}
-
- {RECRUIT_INFO_LIST.map(({ label, value }) => (
-
- {label}: {value}
-
- ))}
-
+
+ {isAuthenticated && !isEditing && (
+
+ )}
-
- 지원하기
-
-
+ {isEditing ? (
+
setIsEditing(false)} />
+ ) : (
+ <>
+ {/* 메인 카피 */}
+
+ {highlightOne(recruitHeading)}
+
+
+ {/* 모집 정보 – 카드 없이 텍스트 나열 */}
+
+ {recruitInfoList.map(({ label, value }) => (
+
+ {label}: {value}
+
+ ))}
+
+
+
+ 지원하기
+
+
+ >
+ )}
);
diff --git a/src/pages/visitor/main/TimelineSection.jsx b/src/pages/visitor/main/TimelineSection.jsx
index 42522bb..068a3dd 100644
--- a/src/pages/visitor/main/TimelineSection.jsx
+++ b/src/pages/visitor/main/TimelineSection.jsx
@@ -1,103 +1,466 @@
-import { useState } from 'react';
+import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import useScrollReveal from '@/hooks/useScrollReveal';
-import { TIMELINE_LIST } from '@/constants/homeData';
+import useAuthStore from '@/stores/authStore';
+import useHomeContentStore from '@/stores/homeContentStore';
+import editIcon from '@/assets/images/editicon.svg';
+import deleteIcon from '@/assets/images/deleteicon.svg';
+
+function toMonthInputValue(monthText) {
+ return monthText ? monthText.trim().replace('.', '-') : '';
+}
+
+function parsePeriod(period) {
+ const trimmed = (period || '').trim();
+ if (!trimmed) return { periodStart: '', periodEnd: '' };
+ if (trimmed.endsWith('-')) {
+ return { periodStart: toMonthInputValue(trimmed.slice(0, -1)), periodEnd: '' };
+ }
+ if (trimmed.startsWith('-')) {
+ return { periodStart: '', periodEnd: toMonthInputValue(trimmed.slice(1)) };
+ }
+ const [start, end] = trimmed.split('-').map((part) => part.trim());
+ return { periodStart: toMonthInputValue(start), periodEnd: toMonthInputValue(end) };
+}
+
+function formatPeriod(periodStart, periodEnd) {
+ const start = periodStart ? periodStart.replace('-', '.') : '';
+ const end = periodEnd ? periodEnd.replace('-', '.') : '';
+ if (start && end) return `${start} - ${end}`;
+ if (start) return `${start} -`;
+ if (end) return `- ${end}`;
+ return '';
+}
+
+const EMPTY_EVENT = {
+ year: '',
+ projectName: '',
+ award: '',
+ activity: '',
+ period: '',
+ memberCount: null,
+ techStack: [],
+ description: '',
+ images: [],
+};
+
+const TimelineEditForm = forwardRef(function TimelineEditForm({ event, onSave, onCancel, showSaveButton = false }, ref) {
+ const [year, setYear] = useState(event.year);
+ const [projectName, setProjectName] = useState(event.projectName);
+ const [award, setAward] = useState(event.award);
+ const [activity, setActivity] = useState(event.activity);
+ const initialPeriod = parsePeriod(event.period);
+ const [periodStart, setPeriodStart] = useState(initialPeriod.periodStart);
+ const [periodEnd, setPeriodEnd] = useState(initialPeriod.periodEnd);
+ const [memberCount, setMemberCount] = useState(
+ event.memberCount === null || event.memberCount === undefined ? '' : String(event.memberCount)
+ );
+ const [techStack, setTechStack] = useState(event.techStack.length > 0 ? [...event.techStack] : ['']);
+ const [description, setDescription] = useState(event.description);
+ const [images, setImages] = useState([...event.images]);
+ const createdObjectUrlsRef = useRef(new Set());
+
+ useEffect(() => {
+ const createdObjectUrls = createdObjectUrlsRef.current;
+ return () => {
+ createdObjectUrls.forEach((url) => URL.revokeObjectURL(url));
+ };
+ }, []);
+
+ const inputClass = 'w-full rounded-md border border-line px-3 py-2 text-sm text-ink';
+
+ const handleMemberCountChange = (value) => {
+ setMemberCount(value.replace(/\D/g, ''));
+ };
+
+ const handleTechStackChange = (index, value) => {
+ setTechStack((previous) => previous.map((tech, i) => (i === index ? value : tech)));
+ };
+
+ const handleTechStackKeyDown = (index, e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ setTechStack((previous) => [...previous.slice(0, index + 1), '', ...previous.slice(index + 1)]);
+ }
+ };
+
+ const handleAddTechStack = () => {
+ setTechStack((previous) => [...previous, '']);
+ };
+
+ const handleRemoveTechStack = (index) => {
+ setTechStack((previous) => previous.filter((_, i) => i !== index));
+ };
+
+ const handleAddImage = (e) => {
+ const file = e.target.files[0];
+ e.target.value = '';
+ if (!file) return;
+ if (!file.type.startsWith('image/')) {
+ alert('이미지 파일만 업로드할 수 있습니다.');
+ return;
+ }
+ const url = URL.createObjectURL(file);
+ createdObjectUrlsRef.current.add(url);
+ setImages((previous) => [...previous, url]);
+ };
+
+ const handleRemoveImage = (index) => {
+ setImages((previous) => {
+ const removed = previous[index];
+ if (createdObjectUrlsRef.current.has(removed)) {
+ URL.revokeObjectURL(removed);
+ createdObjectUrlsRef.current.delete(removed);
+ }
+ return previous.filter((_, i) => i !== index);
+ });
+ };
+
+ const handleSave = () => {
+ if (periodStart && periodEnd && periodStart > periodEnd) {
+ alert('기간 설정이 잘못되었습니다.');
+ return;
+ }
+
+ const validTechStack = techStack.map((tech) => tech.trim()).filter(Boolean);
+ if (validTechStack.length === 0) {
+ alert('기술 스택을 입력해주세요.');
+ return;
+ }
+
+ createdObjectUrlsRef.current.clear();
+ onSave({
+ year,
+ projectName,
+ award,
+ activity,
+ period: formatPeriod(periodStart, periodEnd),
+ memberCount: memberCount === '' ? null : Number(memberCount),
+ techStack: validTechStack,
+ description,
+ images,
+ });
+ };
+
+ useImperativeHandle(ref, () => ({ requestSave: handleSave }));
+
+ return (
+
e.stopPropagation()}>
+
+ setYear(e.target.value)}
+ placeholder="연도"
+ maxLength={9}
+ />
+ setProjectName(e.target.value)}
+ placeholder="프로젝트명"
+ maxLength={30}
+ />
+
+
setAward(e.target.value)}
+ placeholder="수상 내역"
+ maxLength={50}
+ />
+
setActivity(e.target.value)}
+ placeholder="주요 활동 요약"
+ maxLength={60}
+ />
+
+
+
+
+
팀원
+
+ handleMemberCountChange(e.target.value)}
+ placeholder="숫자만 입력"
+ maxLength={3}
+ />
+ 명
+
+
+
+
+
+
기술 스택
+
+ {techStack.map((tech, index) => (
+
+ handleTechStackChange(index, e.target.value)}
+ onKeyDown={(e) => handleTechStackKeyDown(index, e)}
+ placeholder="기술 스택"
+ maxLength={20}
+ className="w-24 bg-transparent text-xs text-brand outline-none"
+ />
+
+
+ ))}
+
+
+
+
+
+ 프로젝트 소개
+
+
+
+
이미지
+
+ {images.map((src, index) => (
+
+

+
+
+ ))}
+ {images.length < 3 && (
+
+ )}
+
+
+
+
+
+ {showSaveButton && (
+
+ )}
+
+
+ );
+});
+
+function TimelineControls({ isRight, isEditing, onEdit, onDelete }) {
+ const editButton = (
+
+ );
+ const deleteButton = (
+
+ );
+
+ return (
+
e.stopPropagation()}>
+ {isRight ? (
+ <>
+ {editButton}
+ {deleteButton}
+ >
+ ) : (
+ <>
+ {deleteButton}
+ {editButton}
+ >
+ )}
+
+ );
+}
+
+function TimelineCard({ event, isRight, isEditing, formRef, onSaveEdit, onCancelEdit }) {
+ const [isExpanded, setIsExpanded] = useState(false);
-function TimelineCard({ event, isRight, isExpanded, onToggle }) {
const handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
- onToggle();
+ setIsExpanded((prev) => !prev);
}
};
return (
!isEditing && setIsExpanded((prev) => !prev)}
onKeyDown={handleKeyDown}
- role="button"
- tabIndex={0}
+ role={isEditing ? undefined : 'button'}
+ tabIndex={isEditing ? undefined : 0}
aria-expanded={isExpanded}
- className={`bg-white rounded-2xl px-9 py-6 border cursor-pointer select-none transition-all duration-300 ${
- isExpanded ? 'border-brand shadow-md' : 'border-[#EDCFBC] hover:border-brand hover:shadow-sm'
- } ${isRight ? 'text-left' : 'text-right'}`}
+ className={`relative bg-white rounded-2xl px-9 py-6 border shadow-md transition-all duration-300 ${
+ isExpanded ? 'border-brand' : 'border-[#EDCFBC]'
+ } ${isRight ? 'text-left' : 'text-right'} ${isEditing ? '' : 'cursor-pointer select-none'}`}
>
- {/* 기본 정보 */}
-
-
- {event.year}
- {event.projectName ? `, ${event.projectName}` : ''}
-
-
{event.award}
-
{event.activity}
-
+
- {/* 펼쳐지는 상세 내용 */}
-
-
-
- {/* 기간 / 인원 */}
-
-
- 📅 {event.period}
-
- {event.memberCount && (
-
- 👥 {event.memberCount}명
-
- )}
-
+ {isEditing ? (
+
+ ) : (
+ <>
+
+
+ {event.year}
+ {event.projectName ? `, ${event.projectName}` : ''}
+
+
{event.award}
+
{event.activity}
+
+
+
+
- {/* 기술 스택 */}
- {event.techStack.length > 0 && (
-
-
기술 스택
-
- {event.techStack.map((tech) => (
-
- {tech}
+
+
+ 📅 {event.period}
+
+ {event.memberCount && (
+
+ 👥 {event.memberCount}명
- ))}
+ )}
-
- )}
- {/* 프로젝트 소개 */}
-
-
프로젝트 소개
-
{event.description}
-
+ {event.techStack && event.techStack.length > 0 && (
+
+
기술 스택
+
+ {event.techStack.map((tech) => (
+
+ {tech}
+
+ ))}
+
+
+ )}
- {/* 이미지 */}
- {event.images.length > 0 && (
-
- {event.images.map((src, index) => (
-

- ))}
+
+
프로젝트 소개
+
{event.description}
+
+
+ {event.images && event.images.length > 0 && (
+
+ {event.images.map((src, index) => (
+

+ ))}
+
+ )}
- )}
-
+ >
+ )}
);
}
-export default function TimelineSection() {
+export default function TimelineSection({ isEditable = false }) {
const revealRef = useScrollReveal();
- const [expandedIndex, setExpandedIndex] = useState(null);
+ const isAuthenticated = useAuthStore((state) => state.isAuthenticated) && isEditable;
+ const timeline = useHomeContentStore((state) => state.timeline);
+ const updateTimelineItem = useHomeContentStore((state) => state.updateTimelineItem);
+ const deleteTimelineItem = useHomeContentStore((state) => state.deleteTimelineItem);
+ const addTimelineItem = useHomeContentStore((state) => state.addTimelineItem);
+
+ const [editingIndex, setEditingIndex] = useState(null);
+ const [isAdding, setIsAdding] = useState(false);
+ const editFormRefs = useRef({});
+
+ const handleDelete = (index) => {
+ if (window.confirm('삭제하시겠습니까?')) {
+ deleteTimelineItem(index);
+ setEditingIndex((previous) => (previous === index ? null : previous));
+ }
+ };
+
+ const handleEditButtonClick = (index) => {
+ if (editingIndex === index) {
+ editFormRefs.current[index]?.requestSave();
+ } else {
+ setEditingIndex(index);
+ }
+ };
- const handleToggle = (index) => {
- setExpandedIndex((previous) => (previous === index ? null : index));
+ const handleSaveNew = (item) => {
+ const nextSide = timeline.length % 2 === 0 ? 'right' : 'left';
+ addTimelineItem({ ...item, side: nextSide });
+ setIsAdding(false);
};
return (
@@ -117,12 +480,36 @@ export default function TimelineSection() {
className="absolute top-0 bottom-0 w-[1.5px] left-1/2 -translate-x-1/2 bg-brand opacity-50"
/>
- {TIMELINE_LIST.map((event, index) => {
+ {timeline.map((event, index) => {
const isRight = event.side === 'right';
- const isExpanded = expandedIndex === index;
+ const isEditing = editingIndex === index;
+ const card = (
+
{
+ editFormRefs.current[index] = el;
+ }}
+ onSaveEdit={(patch) => {
+ updateTimelineItem(index, patch);
+ setEditingIndex(null);
+ }}
+ onCancelEdit={() => setEditingIndex(null)}
+ />
+ );
+ const controls = isAuthenticated && (
+ handleEditButtonClick(index)}
+ onDelete={() => handleDelete(index)}
+ />
+ );
+
return (
{!isRight && (
-
handleToggle(index)}
- />
+
)}
@@ -147,17 +532,44 @@ export default function TimelineSection() {
{/* 오른쪽 */}
{isRight && (
-
handleToggle(index)}
- />
+
)}
);
})}
+
+ {isAdding && (
+
+
+
+
+
+ setIsAdding(false)}
+ showSaveButton
+ />
+
+
+
+ )}
+
+ {isAuthenticated && !isAdding && (
+
+ )}
{/* 하단 연결선 — RecruitSection 점선과 이어짐 */}
diff --git a/src/stores/homeContentStore.js b/src/stores/homeContentStore.js
new file mode 100644
index 0000000..867d5f2
--- /dev/null
+++ b/src/stores/homeContentStore.js
@@ -0,0 +1,44 @@
+import { create } from 'zustand';
+import { ACTIVITY_LIST, TIMELINE_LIST, RECRUIT_INFO_LIST } from '@/constants/homeData';
+
+/**
+ * 메인페이지 콘텐츠(주요 활동/ONE 활동 현황/신입 부원 모집) 전역 상태.
+ * 백엔드 API가 아직 없어 homeData.jsx의 정적 배열을 초기값으로 복사해 사용한다.
+ * 새로고침 시 초기값으로 되돌아간다(영구 저장 아님).
+ */
+const useHomeContentStore = create((set) => ({
+ activities: ACTIVITY_LIST.map((item) => ({ ...item })),
+ timeline: TIMELINE_LIST.map((item) => ({
+ ...item,
+ techStack: [...item.techStack],
+ images: [...item.images],
+ })),
+ recruitHeading: '단순히 배우는 것을 넘어,\n함께 성장할 ONE의 새로운 부원을 모집합니다.',
+ recruitInfoList: RECRUIT_INFO_LIST.map((item) => ({ ...item })),
+
+ updateActivity: (index, patch) =>
+ set((state) => ({
+ activities: state.activities.map((item, i) => (i === index ? { ...item, ...patch } : item)),
+ })),
+
+ updateTimelineItem: (index, patch) =>
+ set((state) => ({
+ timeline: state.timeline.map((item, i) => (i === index ? { ...item, ...patch } : item)),
+ })),
+ deleteTimelineItem: (index) =>
+ set((state) => ({
+ timeline: state.timeline.filter((_, i) => i !== index),
+ })),
+ addTimelineItem: (item) =>
+ set((state) => ({
+ timeline: [...state.timeline, item],
+ })),
+
+ updateRecruitContent: (heading, values) =>
+ set((state) => ({
+ recruitHeading: heading,
+ recruitInfoList: state.recruitInfoList.map((item, i) => ({ ...item, value: values[i] })),
+ })),
+}));
+
+export default useHomeContentStore;