Skip to content
Open
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
54 changes: 54 additions & 0 deletions src/apis/home.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import instance from './instance';

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<Object>} { 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<Object>} { 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<Object>} { 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}`);
36 changes: 36 additions & 0 deletions src/apis/upload.js
Original file line number Diff line number Diff line change
@@ -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<string>} objectKey
*/
export const uploadFile = async (type, file) => {
const { uploadUrl, objectKey } = await getUploadUrl(type);
await uploadFileToPresignedUrl(uploadUrl, file);
return objectKey;
};
3 changes: 3 additions & 0 deletions src/constants/messages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const HOME_CONTENT_FETCH_ERROR_MESSAGE = '콘텐츠를 불러오지 못했습니다.';
export const ACTIVITY_CARD_UPDATE_ERROR_MESSAGE = '카드 수정에 실패했습니다.';
export const ACTIVITY_CARD_CLEAR_ERROR_MESSAGE = '카드 초기화에 실패했습니다.';
49 changes: 49 additions & 0 deletions src/pages/admin/AdminPage.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { useCallback, useEffect, useState } from 'react';
import ActivitiesSection from '@/pages/visitor/main/ActivitiesSection';
import TimelineSection from '@/pages/visitor/main/TimelineSection';
import RecruitSection from '@/pages/visitor/main/RecruitSection';
import { getVisitorMain } from '@/apis/home';
import useHomeContentStore from '@/stores/homeContentStore';
Comment thread
PJW03 marked this conversation as resolved.
import { HOME_CONTENT_FETCH_ERROR_MESSAGE } from '@/constants/messages';

import HeroSection from '../visitor/main/HeroSection';

/**
Expand All @@ -10,6 +15,50 @@ import HeroSection from '../visitor/main/HeroSection';
* 방문자용 HomePage는 isEditable을 넘기지 않아 로그인 여부와 무관하게 항상 일반 화면만 보인다.
*/
function AdminPage() {
const setHomeContent = useHomeContentStore((state) => state.setHomeContent);
const [status, setStatus] = useState('loading');
const [retryCount, setRetryCount] = useState(0);

useEffect(() => {
let ignore = false;
setStatus('loading');

getVisitorMain()
.then((data) => {
if (ignore) return;
setHomeContent(data);
setStatus('success');
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.catch((error) => {
if (ignore) return;
console.error('[AdminPage] 메인페이지 콘텐츠 조회 실패', error);
setStatus('error');
Comment thread
PJW03 marked this conversation as resolved.
});

return () => {
ignore = true;
};
}, [setHomeContent, retryCount]);

const handleRetry = useCallback(() => {
setRetryCount((count) => count + 1);
}, []);

if (status === 'loading') {
return <section>콘텐츠를 불러오는 중입니다...</section>;
}

if (status === 'error') {
return (
<section>
{HOME_CONTENT_FETCH_ERROR_MESSAGE}
<button type="button" onClick={handleRetry}>
다시 시도
</button>
</section>
Comment thread
PJW03 marked this conversation as resolved.
);
}

return (
<section>
<HeroSection />
Expand Down
41 changes: 41 additions & 0 deletions src/pages/visitor/HomePage.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,52 @@
import { useCallback, useEffect, useState } from 'react';
import { getVisitorMain } from '@/apis/home';
import useHomeContentStore from '@/stores/homeContentStore';
import { HOME_CONTENT_FETCH_ERROR_MESSAGE } from '@/constants/messages';

import HeroSection from './main/HeroSection';
import ActivitiesSection from './main/ActivitiesSection';
import TimelineSection from './main/TimelineSection';
import RecruitSection from './main/RecruitSection';

export default function HomePage() {
const setHomeContent = useHomeContentStore((state) => state.setHomeContent);
const [hasError, setHasError] = useState(false);
const [retryCount, setRetryCount] = useState(0);

useEffect(() => {
let ignore = false;
setHasError(false);

getVisitorMain()
.then((data) => {
if (ignore) return;
setHomeContent(data);
})
.catch((error) => {
if (ignore) return;
console.error('[HomePage] 메인페이지 콘텐츠 조회 실패', error);
setHasError(true);
});

return () => {
ignore = true;
};
}, [setHomeContent, retryCount]);

const handleRetry = useCallback(() => {
setRetryCount((count) => count + 1);
}, []);

return (
<>
{hasError && (
<div>
{HOME_CONTENT_FETCH_ERROR_MESSAGE}
<button type="button" onClick={handleRetry}>
다시 시도
</button>
</div>
)}
<HeroSection />
<ActivitiesSection />
<TimelineSection />
Expand Down
44 changes: 35 additions & 9 deletions src/pages/visitor/main/ActivitiesSection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ 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 { ACTIVITY_CARD_UPDATE_ERROR_MESSAGE, ACTIVITY_CARD_CLEAR_ERROR_MESSAGE } from '@/constants/messages';
import editIcon from '@/assets/images/editicon.svg';
import deleteIcon from '@/assets/images/deleteicon.svg';

Expand All @@ -14,6 +16,7 @@ export default function ActivitiesSection({ isEditable = false }) {
const [editingIndex, setEditingIndex] = useState(null);
const [draftTitle, setDraftTitle] = useState('');
const [draftDescription, setDraftDescription] = useState('');
const [isSaving, setIsSaving] = useState(false);

const startEdit = (index) => {
setEditingIndex(index);
Expand Down Expand Up @@ -42,18 +45,39 @@ export default function ActivitiesSection({ isEditable = false }) {
}
};

const handleSaveClick = (index) => {
const handleSaveClick = async (index) => {
if (isSaving) return;
if (!draftTitle.trim() || !draftDescription.trim()) {
alert('내용을 입력해 주세요.');
return;
}
updateActivity(index, { title: draftTitle, description: draftDescription });
setEditingIndex(null);

const cardId = activities[index].cardId;
setIsSaving(true);
try {
await updateActivityCard(cardId, { title: draftTitle, content: draftDescription });
updateActivity(index, { title: draftTitle, description: draftDescription });
setEditingIndex(null);
} catch {
alert(ACTIVITY_CARD_UPDATE_ERROR_MESSAGE);
} finally {
setIsSaving(false);
}
};

const handleDeleteContent = (index) => {
updateActivity(index, { title: '', description: '' });
setEditingIndex((previous) => (previous === index ? null : previous));
const handleDeleteContent = async (index) => {
if (isSaving) return;
const cardId = activities[index].cardId;
setIsSaving(true);
try {
await clearActivityCard(cardId);
updateActivity(index, { title: '', description: '' });
setEditingIndex((previous) => (previous === index ? null : previous));
} catch {
alert(ACTIVITY_CARD_CLEAR_ERROR_MESSAGE);
} finally {
setIsSaving(false);
}
};

return (
Expand Down Expand Up @@ -81,20 +105,22 @@ export default function ActivitiesSection({ isEditable = false }) {
<button
type="button"
onClick={() => (isEditing ? handleSaveClick(index) : startEdit(index))}
disabled={isSaving}
aria-label={isEditing ? '저장' : '수정'}
className={
className={`disabled:opacity-50 ${
isEditing
? 'rounded-full bg-brand px-3 py-1 text-xs font-bold text-white'
: 'flex h-6 w-6 items-center justify-center rounded-full transition-colors hover:bg-brand-soft'
}
}`}
>
{isEditing ? '저장' : <img src={editIcon} alt="" className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => handleDeleteContent(index)}
disabled={isSaving}
aria-label="삭제"
className="flex h-6 w-6 items-center justify-center rounded-full transition-colors hover:bg-brand-soft"
className="flex h-6 w-6 items-center justify-center rounded-full transition-colors hover:bg-brand-soft disabled:opacity-50"
>
<img src={deleteIcon} alt="" className="h-4 w-4" />
</button>
Expand Down
10 changes: 7 additions & 3 deletions src/pages/visitor/main/HeroSection.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import HeroScene from '@/three/scenes/HeroScene';
import useHomeContentStore from '@/stores/homeContentStore';

// clamp()는 Tailwind 임의 값으로 표현 불가 → inline 유지
const ONE_LETTER_BASE_STYLE = {
Expand All @@ -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 (
<section
className="relative min-h-screen overflow-hidden flex items-center justify-center"
Expand Down Expand Up @@ -44,11 +49,10 @@ export default function HeroSection() {
</div>

<p
className="flex items-center justify-center gap-2 font-medium text-[#7A4A28]"
className="font-medium text-[#7A4A28]"
style={{ fontSize: 'clamp(1rem, 2.5vw, 1.4rem)' }}
>
아이디어를 현실로 구현하는 공간,
<strong className="text-brand">ONE</strong>
{mainDescription || DEFAULT_DESCRIPTION}
</p>
</div>

Expand Down
Loading