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
3 changes: 3 additions & 0 deletions src/assets/images/deleteicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/images/editicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 13 additions & 5 deletions src/hooks/useScrollReveal.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { useRef, useEffect, useCallback } from 'react';
* @returns {Function} ref 콜백 — 감시할 DOM 요소에 ref={revealRef} 로 전달
*/
export default function useScrollReveal() {
const revealRefs = useRef([]);
const observerRef = useRef(null);
const elementsRef = useRef([]);

useEffect(() => {
const observer = new IntersectionObserver(
Expand All @@ -20,14 +21,21 @@ export default function useScrollReveal() {
},
{ threshold: 0.1 }
);
observerRef.current = observer;

revealRefs.current.forEach((element) => element && observer.observe(element));
return () => observer.disconnect();
elementsRef.current.forEach((element) => observer.observe(element));

return () => {
observer.disconnect();
observerRef.current = null;
};
}, []);

return useCallback((element) => {
if (element && !revealRefs.current.includes(element)) {
revealRefs.current.push(element);
if (!element) return;
if (!elementsRef.current.includes(element)) {
elementsRef.current.push(element);
}
observerRef.current?.observe(element);
}, []);
}
17 changes: 15 additions & 2 deletions src/pages/admin/AdminPage.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
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';

/**
* 관리자 홈 콘텐츠 관리 페이지.
* 주요 활동/ONE 활동 현황/신입 부원 모집 섹션을 그대로 재사용한다.
* isEditable을 켜서 렌더링하므로, 관리자 로그인 상태(isAuthenticated)일 때만 수정/삭제 아이콘을 노출한다.
* 방문자용 HomePage는 isEditable을 넘기지 않아 로그인 여부와 무관하게 항상 일반 화면만 보인다.
*/
function AdminPage() {
return (
<section>
<h2>관리자 페이지</h2>
<p>신청 부원 정보, 수상경력, 연간 계획, 동아리 소개, 명부 관리 (CRUD)</p>
<HeroSection />
<ActivitiesSection isEditable />
<TimelineSection isEditable />
<RecruitSection isEditable />
</section>
);
}
Expand Down
149 changes: 127 additions & 22 deletions src/pages/visitor/main/ActivitiesSection.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,60 @@
import { useState } from 'react';
import useScrollReveal from '@/hooks/useScrollReveal';
import { ACTIVITY_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';

export default function ActivitiesSection() {
export default function ActivitiesSection({ isEditable = false }) {
const revealRef = useScrollReveal();
const isAuthenticated = useAuthStore((state) => state.isAuthenticated) && isEditable;
const activities = useHomeContentStore((state) => state.activities);
const updateActivity = useHomeContentStore((state) => state.updateActivity);

const [editingIndex, setEditingIndex] = useState(null);
const [draftTitle, setDraftTitle] = useState('');
const [draftDescription, setDraftDescription] = useState('');

const startEdit = (index) => {
setEditingIndex(index);
setDraftTitle(activities[index].title);
setDraftDescription(activities[index].description);
};

const DESCRIPTION_LINE_LENGTH = 30;

const handleDescriptionChange = (value) => {
let [firstLine, ...restLines] = value.split('\n');
let secondLine = restLines.join('');

if (firstLine.length > DESCRIPTION_LINE_LENGTH) {
secondLine = firstLine.slice(DESCRIPTION_LINE_LENGTH) + secondLine;
firstLine = firstLine.slice(0, DESCRIPTION_LINE_LENGTH);
}
secondLine = secondLine.slice(0, DESCRIPTION_LINE_LENGTH);

setDraftDescription(restLines.length > 0 || secondLine ? `${firstLine}\n${secondLine}` : firstLine);
};

const handleDescriptionKeyDown = (event) => {
if (event.key === 'Enter' && draftDescription.includes('\n')) {
event.preventDefault();
}
};

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

const handleDeleteContent = (index) => {
updateActivity(index, { title: '', description: '' });
setEditingIndex((previous) => (previous === index ? null : previous));
};

return (
<section className="py-20 px-6 bg-brand-soft">
Expand All @@ -15,27 +67,80 @@ export default function ActivitiesSection() {

{/* 2×2 카드 그리드 */}
<div className="grid grid-cols-2 gap-5">
{ACTIVITY_LIST.map((activity, index) => (
<div
key={activity.title}
ref={revealRef}
className="reveal-up bg-white rounded-2xl p-12 border border-[#EDCFBC] flex flex-col items-center text-center"
style={{ transitionDelay: `${index * 80}ms` }}
>
{/* 원형 아이콘 컨테이너 */}
<div className="w-24 h-24 rounded-full flex items-center justify-center mb-7 bg-brand-soft">
<img
src={activity.icon}
alt={activity.title}
className="w-[100px] h-[100px] object-cover"
/>
{activities.map((activity, index) => {
const isEditing = editingIndex === index;
return (
<div
key={index}
ref={revealRef}
className="reveal-up relative bg-white rounded-2xl p-12 border border-[#EDCFBC] flex flex-col items-center text-center"
style={{ transitionDelay: `${index * 80}ms` }}
>
{isAuthenticated && (
<div className="absolute top-4 right-4 flex items-center gap-3">
<button
type="button"
onClick={() => (isEditing ? handleSaveClick(index) : startEdit(index))}
aria-label={isEditing ? '저장' : '수정'}
className={
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)}
aria-label="삭제"
className="flex h-6 w-6 items-center justify-center rounded-full transition-colors hover:bg-brand-soft"
>
<img src={deleteIcon} alt="" className="h-4 w-4" />
</button>
</div>
)}

{/* 원형 아이콘 컨테이너 */}
<div className="w-24 h-24 rounded-full flex items-center justify-center mb-7 bg-brand-soft">
<img
src={activity.icon}
alt={activity.title}
className="w-[100px] h-[100px] object-cover"
/>
</div>

{isEditing ? (
<>
<input
type="text"
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
placeholder="활동 제목"
maxLength={20}
className="mb-5 w-full rounded-md border border-line px-3 py-2 text-center text-lg font-bold text-ink"
/>
<textarea
value={draftDescription}
onChange={(event) => handleDescriptionChange(event.target.value)}
onKeyDown={handleDescriptionKeyDown}
placeholder="활동 설명 (최대 2줄)"
rows={2}
maxLength={61}
className="w-full resize-none rounded-md border border-line px-3 py-2 text-center text-sm text-ink-sub"
/>
</>
) : (
<>
<h3 className="w-full text-lg font-bold text-ink mb-5 break-words">{activity.title}</h3>
<p className="w-full text-sm text-ink-sub leading-7 m-0 whitespace-pre-line break-words">
{activity.description}
</p>
</>
)}
</div>
<h3 className="text-lg font-bold text-ink mb-5">{activity.title}</h3>
<p className="text-sm text-ink-sub leading-7 m-0 whitespace-pre-line">
{activity.description}
</p>
</div>
))}
);
})}
</div>
</div>
</section>
Expand Down
Loading