From 77e7ad11cc306182486a236bbfc8923854ee737d Mon Sep 17 00:00:00 2001 From: PJW Date: Fri, 21 Aug 2026 13:54:48 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat-web-28=20=EB=A9=94=EC=9D=B8=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/home.js | 6 +++ src/pages/visitor/HomePage.jsx | 19 ++++++++++ src/pages/visitor/main/HeroSection.jsx | 10 +++-- src/stores/homeContentStore.js | 52 +++++++++++++++++++++++++- 4 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 src/apis/home.js diff --git a/src/apis/home.js b/src/apis/home.js new file mode 100644 index 0000000..78ea4de --- /dev/null +++ b/src/apis/home.js @@ -0,0 +1,6 @@ +import instance from './instance'; + +export const getVisitorMain = async () => { + const response = await instance.get('/api/v1/visitor/main'); + return response.data?.data; +}; diff --git a/src/pages/visitor/HomePage.jsx b/src/pages/visitor/HomePage.jsx index b2d692f..3785071 100644 --- a/src/pages/visitor/HomePage.jsx +++ b/src/pages/visitor/HomePage.jsx @@ -1,9 +1,28 @@ +import { useEffect } from 'react'; import HeroSection from './main/HeroSection'; import ActivitiesSection from './main/ActivitiesSection'; import TimelineSection from './main/TimelineSection'; import RecruitSection from './main/RecruitSection'; +import { getVisitorMain } from '@/apis/home'; +import useHomeContentStore from '@/stores/homeContentStore'; export default function HomePage() { + const setHomeContent = useHomeContentStore((state) => state.setHomeContent); + + useEffect(() => { + let ignore = false; + + getVisitorMain() + .then((data) => { + if (!ignore) setHomeContent(data); + }) + .catch(() => {}); + + return () => { + ignore = true; + }; + }, [setHomeContent]); + return ( <> diff --git a/src/pages/visitor/main/HeroSection.jsx b/src/pages/visitor/main/HeroSection.jsx index 3687b24..859e96a 100644 --- a/src/pages/visitor/main/HeroSection.jsx +++ b/src/pages/visitor/main/HeroSection.jsx @@ -1,4 +1,5 @@ import HeroScene from '@/three/scenes/HeroScene'; +import useHomeContentStore from '@/stores/homeContentStore'; // clamp()는 Tailwind 임의 값으로 표현 불가 → inline 유지 const ONE_LETTER_BASE_STYLE = { @@ -9,7 +10,11 @@ const ONE_LETTER_BASE_STYLE = { textShadow: '0 4px 24px rgba(196,120,64,0.18)', }; +const DEFAULT_DESCRIPTION = '아이디어를 현실로 구현하는 공간, ONE'; + export default function HeroSection() { + const mainDescription = useHomeContentStore((state) => state.mainDescription); + return (

- 아이디어를 현실로 구현하는 공간, - ONE + {mainDescription || DEFAULT_DESCRIPTION}

diff --git a/src/stores/homeContentStore.js b/src/stores/homeContentStore.js index 867d5f2..ddd8a9e 100644 --- a/src/stores/homeContentStore.js +++ b/src/stores/homeContentStore.js @@ -1,12 +1,45 @@ import { create } from 'zustand'; import { ACTIVITY_LIST, TIMELINE_LIST, RECRUIT_INFO_LIST } from '@/constants/homeData'; +const formatMonth = (dateText) => (dateText ? dateText.slice(0, 7).replace('-', '.') : ''); + +const mapActivityCards = (activityCards) => { + if (!Array.isArray(activityCards) || activityCards.length === 0) return null; + + return [...activityCards] + .sort((a, b) => a.cardOrder - b.cardOrder) + .map((card, index) => ({ + icon: ACTIVITY_LIST[index % ACTIVITY_LIST.length].icon, + title: card.title, + description: card.content, + })); +}; + +const mapProjectDetails = (projectDetails) => { + if (!Array.isArray(projectDetails) || projectDetails.length === 0) return null; + + return projectDetails.map((project, index) => ({ + year: project.year, + projectName: project.projectName, + award: project.award, + activity: project.activity, + side: index % 2 === 0 ? 'right' : 'left', + period: `${formatMonth(project.startDate)} - ${formatMonth(project.endDate)}`, + memberCount: project.participantCount ?? null, + techStack: project.techStacks ?? [], + description: project.description, + images: (project.photos ?? []).map((photo) => photo.url), + })); +}; + /** * 메인페이지 콘텐츠(주요 활동/ONE 활동 현황/신입 부원 모집) 전역 상태. - * 백엔드 API가 아직 없어 homeData.jsx의 정적 배열을 초기값으로 복사해 사용한다. - * 새로고침 시 초기값으로 되돌아간다(영구 저장 아님). + * 초기값은 homeData.jsx의 정적 배열이며, setHomeContent로 방문자 메인페이지 API(GET /api/v1/visitor/main) + * 응답을 반영해 덮어쓴다. */ const useHomeContentStore = create((set) => ({ + logoUrl: '', + mainDescription: '', activities: ACTIVITY_LIST.map((item) => ({ ...item })), timeline: TIMELINE_LIST.map((item) => ({ ...item, @@ -16,6 +49,21 @@ const useHomeContentStore = create((set) => ({ recruitHeading: '단순히 배우는 것을 넘어,\n함께 성장할 ONE의 새로운 부원을 모집합니다.', recruitInfoList: RECRUIT_INFO_LIST.map((item) => ({ ...item })), + setHomeContent: (data) => + set((state) => { + if (!data) return state; + + const activities = mapActivityCards(data.activityCards); + const timeline = mapProjectDetails(data.projectDetails); + + return { + logoUrl: data.logoUrl ?? state.logoUrl, + mainDescription: data.mainDescription ?? state.mainDescription, + activities: activities ?? state.activities, + timeline: timeline ?? state.timeline, + }; + }), + updateActivity: (index, patch) => set((state) => ({ activities: state.activities.map((item, i) => (i === index ? { ...item, ...patch } : item)), From c4e464ee7213a8cdcb40ccf66b74a5b0215a8635 Mon Sep 17 00:00:00 2001 From: PJW Date: Fri, 21 Aug 2026 15:04:03 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat-web-21=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EB=A9=94=EC=9D=B8=ED=8E=98=EC=9D=B4=EC=A7=80=20api=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/home.js | 48 +++++++ src/apis/upload.js | 36 +++++ src/pages/admin/AdminPage.jsx | 19 +++ src/pages/visitor/main/ActivitiesSection.jsx | 26 +++- src/pages/visitor/main/TimelineSection.jsx | 139 +++++++++++++++---- src/stores/homeContentStore.js | 3 + 6 files changed, 239 insertions(+), 32 deletions(-) create mode 100644 src/apis/upload.js diff --git a/src/apis/home.js b/src/apis/home.js index 78ea4de..ae3b436 100644 --- a/src/apis/home.js +++ b/src/apis/home.js @@ -4,3 +4,51 @@ export const getVisitorMain = async () => { const response = await instance.get('/api/v1/visitor/main'); return response.data?.data; }; + +/** + * (관리자) 주요활동 카드 수정 + * @param {number} cardId - 수정할 카드 ID + * @param {{ title: string, content: string }} data + * @returns {Promise} { cardId, title, content, cardOrder } + */ +export const updateActivityCard = async (cardId, { title, content }) => { + const response = await instance.patch(`/api/v1/admin/main/activity/${cardId}`, { title, content }); + return response.data?.data; +}; + +/** + * (관리자) 주요활동 카드 초기화 + * @param {number} cardId - 초기화할 카드 ID + * @returns {Promise} + */ +export const clearActivityCard = (cardId) => + instance.patch(`/api/v1/admin/main/activity/${cardId}/clear`); + +/** + * (관리자) 프로젝트 생성 + * @param {{ year, projectName, award, activity, startDate, endDate, participantCount, techStacks, description, photoKeys }} data + * @returns {Promise} { projectId, year, projectName, award, activity, startDate, endDate, participantCount, techStacks, description, photos } + */ +export const createProject = async (data) => { + const response = await instance.post('/api/v1/admin/project', data); + return response.data?.data; +}; + +/** + * (관리자) 프로젝트 수정 + * keepPhotoIds로 유지할 기존 사진을, newPhotoKeys로 새로 추가할 사진의 objectKey를 전달한다. + * @param {number} projectId + * @param {{ year, projectName, award, activity, startDate, endDate, participantCount, techStacks, description, keepPhotoIds, newPhotoKeys }} data + * @returns {Promise} { projectId, year, projectName, award, activity, startDate, endDate, participantCount, techStacks, description, photos } + */ +export const updateProject = async (projectId, data) => { + const response = await instance.patch(`/api/v1/admin/project/${projectId}`, data); + return response.data?.data; +}; + +/** + * (관리자) 프로젝트 삭제 + * @param {number} projectId + * @returns {Promise} + */ +export const deleteProject = (projectId) => instance.delete(`/api/v1/admin/project/${projectId}`); diff --git a/src/apis/upload.js b/src/apis/upload.js new file mode 100644 index 0000000..b2e473e --- /dev/null +++ b/src/apis/upload.js @@ -0,0 +1,36 @@ +import instance from './instance'; + +/** + * Presigned 업로드 URL 발급 + * @param {'logo'|'project'} type - 업로드 대상 유형 + * @returns {Promise<{ uploadUrl: string, objectKey: string }>} + */ +export const getUploadUrl = async (type) => { + const response = await instance.get('/api/v1/files/upload-url', { params: { type } }); + return response.data?.data; +}; + +/** + * 발급받은 Presigned URL에 파일을 MinIO로 직접 업로드 + * instance를 거치지 않는다 — baseURL/Authorization 헤더가 MinIO 요청에는 맞지 않기 때문. + * @param {string} uploadUrl + * @param {File} file + */ +const uploadFileToPresignedUrl = async (uploadUrl, file) => { + const response = await fetch(uploadUrl, { method: 'PUT', body: file }); + if (!response.ok) { + throw new Error('파일 업로드에 실패했습니다.'); + } +}; + +/** + * 파일을 업로드하고 objectKey를 반환한다. + * @param {'logo'|'project'} type + * @param {File} file + * @returns {Promise} objectKey + */ +export const uploadFile = async (type, file) => { + const { uploadUrl, objectKey } = await getUploadUrl(type); + await uploadFileToPresignedUrl(uploadUrl, file); + return objectKey; +}; diff --git a/src/pages/admin/AdminPage.jsx b/src/pages/admin/AdminPage.jsx index 73312b1..9afc482 100644 --- a/src/pages/admin/AdminPage.jsx +++ b/src/pages/admin/AdminPage.jsx @@ -1,7 +1,10 @@ +import { useEffect } from 'react'; import ActivitiesSection from '@/pages/visitor/main/ActivitiesSection'; import TimelineSection from '@/pages/visitor/main/TimelineSection'; import RecruitSection from '@/pages/visitor/main/RecruitSection'; import HeroSection from '../visitor/main/HeroSection'; +import { getVisitorMain } from '@/apis/home'; +import useHomeContentStore from '@/stores/homeContentStore'; /** * 관리자 홈 콘텐츠 관리 페이지. @@ -10,6 +13,22 @@ import HeroSection from '../visitor/main/HeroSection'; * 방문자용 HomePage는 isEditable을 넘기지 않아 로그인 여부와 무관하게 항상 일반 화면만 보인다. */ function AdminPage() { + const setHomeContent = useHomeContentStore((state) => state.setHomeContent); + + useEffect(() => { + let ignore = false; + + getVisitorMain() + .then((data) => { + if (!ignore) setHomeContent(data); + }) + .catch(() => {}); + + return () => { + ignore = true; + }; + }, [setHomeContent]); + return (
diff --git a/src/pages/visitor/main/ActivitiesSection.jsx b/src/pages/visitor/main/ActivitiesSection.jsx index 87c2f1a..0b80de8 100644 --- a/src/pages/visitor/main/ActivitiesSection.jsx +++ b/src/pages/visitor/main/ActivitiesSection.jsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import useScrollReveal from '@/hooks/useScrollReveal'; import useAuthStore from '@/stores/authStore'; import useHomeContentStore from '@/stores/homeContentStore'; +import { updateActivityCard, clearActivityCard } from '@/apis/home'; import editIcon from '@/assets/images/editicon.svg'; import deleteIcon from '@/assets/images/deleteicon.svg'; @@ -42,18 +43,31 @@ export default function ActivitiesSection({ isEditable = false }) { } }; - const handleSaveClick = (index) => { + const handleSaveClick = async (index) => { if (!draftTitle.trim() || !draftDescription.trim()) { alert('내용을 입력해 주세요.'); return; } - updateActivity(index, { title: draftTitle, description: draftDescription }); - setEditingIndex(null); + + const cardId = activities[index].cardId; + try { + await updateActivityCard(cardId, { title: draftTitle, content: draftDescription }); + updateActivity(index, { title: draftTitle, description: draftDescription }); + setEditingIndex(null); + } catch { + alert('카드 수정에 실패했습니다.'); + } }; - const handleDeleteContent = (index) => { - updateActivity(index, { title: '', description: '' }); - setEditingIndex((previous) => (previous === index ? null : previous)); + const handleDeleteContent = async (index) => { + const cardId = activities[index].cardId; + try { + await clearActivityCard(cardId); + updateActivity(index, { title: '', description: '' }); + setEditingIndex((previous) => (previous === index ? null : previous)); + } catch { + alert('카드 초기화에 실패했습니다.'); + } }; return ( diff --git a/src/pages/visitor/main/TimelineSection.jsx b/src/pages/visitor/main/TimelineSection.jsx index 068a3dd..49c6466 100644 --- a/src/pages/visitor/main/TimelineSection.jsx +++ b/src/pages/visitor/main/TimelineSection.jsx @@ -2,6 +2,8 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 're import useScrollReveal from '@/hooks/useScrollReveal'; import useAuthStore from '@/stores/authStore'; import useHomeContentStore from '@/stores/homeContentStore'; +import { createProject, updateProject, deleteProject } from '@/apis/home'; +import { uploadFile } from '@/apis/upload'; import editIcon from '@/assets/images/editicon.svg'; import deleteIcon from '@/assets/images/deleteicon.svg'; @@ -9,6 +11,14 @@ function toMonthInputValue(monthText) { return monthText ? monthText.trim().replace('.', '-') : ''; } +function formatMonth(dateText) { + return dateText ? dateText.slice(0, 7).replace('-', '.') : ''; +} + +function toApiDate(monthInputValue) { + return monthInputValue ? `${monthInputValue}-01` : null; +} + function parsePeriod(period) { const trimmed = (period || '').trim(); if (!trimmed) return { periodStart: '', periodEnd: '' }; @@ -41,13 +51,14 @@ const EMPTY_EVENT = { techStack: [], description: '', images: [], + photoIds: [], }; 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 [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); @@ -55,8 +66,10 @@ const TimelineEditForm = forwardRef(function TimelineEditForm({ event, onSave, o 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 [description, setDescription] = useState(event.description ?? ''); + const [photoDrafts, setPhotoDrafts] = useState( + event.images.map((url, i) => ({ url, id: event.photoIds?.[i] ?? null, file: null })) + ); const createdObjectUrlsRef = useRef(new Set()); useEffect(() => { @@ -101,15 +114,15 @@ const TimelineEditForm = forwardRef(function TimelineEditForm({ event, onSave, o } const url = URL.createObjectURL(file); createdObjectUrlsRef.current.add(url); - setImages((previous) => [...previous, url]); + setPhotoDrafts((previous) => [...previous, { url, id: null, file }]); }; const handleRemoveImage = (index) => { - setImages((previous) => { + setPhotoDrafts((previous) => { const removed = previous[index]; - if (createdObjectUrlsRef.current.has(removed)) { - URL.revokeObjectURL(removed); - createdObjectUrlsRef.current.delete(removed); + if (createdObjectUrlsRef.current.has(removed.url)) { + URL.revokeObjectURL(removed.url); + createdObjectUrlsRef.current.delete(removed.url); } return previous.filter((_, i) => i !== index); }); @@ -127,17 +140,18 @@ const TimelineEditForm = forwardRef(function TimelineEditForm({ event, onSave, o return; } - createdObjectUrlsRef.current.clear(); onSave({ year, projectName, award, activity, period: formatPeriod(periodStart, periodEnd), + periodStart, + periodEnd, memberCount: memberCount === '' ? null : Number(memberCount), techStack: validTechStack, description, - images, + photoDrafts, }); }; @@ -261,9 +275,9 @@ const TimelineEditForm = forwardRef(function TimelineEditForm({ event, onSave, o
이미지
- {images.map((src, index) => ( + {photoDrafts.map((draft, index) => (
- +
))} - {images.length < 3 && ( + {photoDrafts.length < 3 && (