diff --git a/src/apis/home.js b/src/apis/home.js new file mode 100644 index 0000000..ae3b436 --- /dev/null +++ b/src/apis/home.js @@ -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} { 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/constants/messages.js b/src/constants/messages.js new file mode 100644 index 0000000..24d1a5c --- /dev/null +++ b/src/constants/messages.js @@ -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 = '카드 초기화에 실패했습니다.'; diff --git a/src/pages/admin/AdminPage.jsx b/src/pages/admin/AdminPage.jsx index 73312b1..4d8e01f 100644 --- a/src/pages/admin/AdminPage.jsx +++ b/src/pages/admin/AdminPage.jsx @@ -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'; +import { HOME_CONTENT_FETCH_ERROR_MESSAGE } from '@/constants/messages'; + import HeroSection from '../visitor/main/HeroSection'; /** @@ -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'); + }) + .catch((error) => { + if (ignore) return; + console.error('[AdminPage] 메인페이지 콘텐츠 조회 실패', error); + setStatus('error'); + }); + + return () => { + ignore = true; + }; + }, [setHomeContent, retryCount]); + + const handleRetry = useCallback(() => { + setRetryCount((count) => count + 1); + }, []); + + if (status === 'loading') { + return
콘텐츠를 불러오는 중입니다...
; + } + + if (status === 'error') { + return ( +
+ {HOME_CONTENT_FETCH_ERROR_MESSAGE} + +
+ ); + } + return (
diff --git a/src/pages/visitor/HomePage.jsx b/src/pages/visitor/HomePage.jsx index b2d692f..a0f672a 100644 --- a/src/pages/visitor/HomePage.jsx +++ b/src/pages/visitor/HomePage.jsx @@ -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 && ( +
+ {HOME_CONTENT_FETCH_ERROR_MESSAGE} + +
+ )} diff --git a/src/pages/visitor/main/ActivitiesSection.jsx b/src/pages/visitor/main/ActivitiesSection.jsx index 87c2f1a..25a983c 100644 --- a/src/pages/visitor/main/ActivitiesSection.jsx +++ b/src/pages/visitor/main/ActivitiesSection.jsx @@ -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'; @@ -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); @@ -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 ( @@ -81,20 +105,22 @@ export default function ActivitiesSection({ isEditable = false }) { 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/pages/visitor/main/TimelineSection.jsx b/src/pages/visitor/main/TimelineSection.jsx index 068a3dd..2c2b201 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,17 @@ 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 TimelineEditForm = forwardRef(function TimelineEditForm( + { event, onSave, onCancel, showSaveButton = false, disabled = 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); @@ -55,8 +69,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,18 +117,16 @@ 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) => { - const removed = previous[index]; - if (createdObjectUrlsRef.current.has(removed)) { - URL.revokeObjectURL(removed); - createdObjectUrlsRef.current.delete(removed); - } - return previous.filter((_, i) => i !== index); - }); + const removed = photoDrafts[index]; + if (removed && createdObjectUrlsRef.current.has(removed.url)) { + URL.revokeObjectURL(removed.url); + createdObjectUrlsRef.current.delete(removed.url); + } + setPhotoDrafts((previous) => previous.filter((_, i) => i !== index)); }; const handleSave = () => { @@ -127,17 +141,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 +276,9 @@ const TimelineEditForm = forwardRef(function TimelineEditForm({ event, onSave, o
이미지
- {images.map((src, index) => ( -
- + {photoDrafts.map((draft, index) => ( +
+
))} - {images.length < 3 && ( + {photoDrafts.length < 3 && (
@@ -438,29 +465,124 @@ export default function TimelineSection({ isEditable = false }) { const deleteTimelineItem = useHomeContentStore((state) => state.deleteTimelineItem); const addTimelineItem = useHomeContentStore((state) => state.addTimelineItem); - const [editingIndex, setEditingIndex] = useState(null); + const [editingProjectId, setEditingProjectId] = useState(null); const [isAdding, setIsAdding] = useState(false); + const [isSaving, setIsSaving] = useState(false); const editFormRefs = useRef({}); - const handleDelete = (index) => { - if (window.confirm('삭제하시겠습니까?')) { - deleteTimelineItem(index); - setEditingIndex((previous) => (previous === index ? null : previous)); + const resolvePhotoKeys = async (photoDrafts) => { + const keepPhotoIds = []; + const newPhotoKeys = []; + for (const draft of photoDrafts) { + if (draft.id !== null && draft.id !== undefined) { + keepPhotoIds.push(draft.id); + } else if (draft.file) { + const objectKey = await uploadFile('project', draft.file); + newPhotoKeys.push(objectKey); + } + } + return { keepPhotoIds, newPhotoKeys }; + }; + + const toStoreItem = (project, side) => ({ + projectId: project.projectId, + year: project.year, + projectName: project.projectName, + award: project.award, + activity: project.activity, + side, + period: `${formatMonth(project.startDate)} - ${formatMonth(project.endDate)}`, + memberCount: project.participantCount ?? null, + techStack: project.techStacks ?? [], + description: project.description, + images: (project.photos ?? []).map((photo) => photo.url), + photoIds: (project.photos ?? []).map((photo) => photo.id), + }); + + const handleDelete = async (index) => { + if (isSaving) return; + if (!window.confirm('삭제하시겠습니까?')) return; + + const projectId = timeline[index].projectId; + setIsSaving(true); + try { + await deleteProject(projectId); + const currentIndex = useHomeContentStore.getState().timeline.findIndex((item) => item.projectId === projectId); + if (currentIndex !== -1) deleteTimelineItem(currentIndex); + setEditingProjectId((previous) => (previous === projectId ? null : previous)); + } catch { + alert('프로젝트 삭제에 실패했습니다.'); + } finally { + setIsSaving(false); } }; const handleEditButtonClick = (index) => { - if (editingIndex === index) { + const projectId = timeline[index].projectId; + if (editingProjectId === projectId) { editFormRefs.current[index]?.requestSave(); } else { - setEditingIndex(index); + setEditingProjectId(projectId); } }; - const handleSaveNew = (item) => { - const nextSide = timeline.length % 2 === 0 ? 'right' : 'left'; - addTimelineItem({ ...item, side: nextSide }); - setIsAdding(false); + const handleSaveEdit = async (index, patch) => { + if (isSaving) return; + const projectId = timeline[index].projectId; + setIsSaving(true); + try { + const { keepPhotoIds, newPhotoKeys } = await resolvePhotoKeys(patch.photoDrafts); + const project = await updateProject(projectId, { + year: patch.year, + projectName: patch.projectName, + award: patch.award, + activity: patch.activity, + startDate: toApiDate(patch.periodStart), + endDate: toApiDate(patch.periodEnd), + participantCount: patch.memberCount, + techStacks: patch.techStack, + description: patch.description, + keepPhotoIds, + newPhotoKeys, + }); + const currentTimeline = useHomeContentStore.getState().timeline; + const currentIndex = currentTimeline.findIndex((item) => item.projectId === projectId); + if (currentIndex !== -1) { + updateTimelineItem(currentIndex, toStoreItem(project, currentTimeline[currentIndex].side)); + } + setEditingProjectId(null); + } catch { + alert('프로젝트 수정에 실패했습니다.'); + } finally { + setIsSaving(false); + } + }; + + const handleSaveNew = async (patch) => { + if (isSaving) return; + setIsSaving(true); + try { + const { newPhotoKeys } = await resolvePhotoKeys(patch.photoDrafts); + const project = await createProject({ + year: patch.year, + projectName: patch.projectName, + award: patch.award, + activity: patch.activity, + startDate: toApiDate(patch.periodStart), + endDate: toApiDate(patch.periodEnd), + participantCount: patch.memberCount, + techStacks: patch.techStack, + description: patch.description, + photoKeys: newPhotoKeys, + }); + const nextSide = timeline.length % 2 === 0 ? 'right' : 'left'; + addTimelineItem(toStoreItem(project, nextSide)); + setIsAdding(false); + } catch { + alert('프로젝트 등록에 실패했습니다.'); + } finally { + setIsSaving(false); + } }; return ( @@ -482,7 +604,7 @@ export default function TimelineSection({ isEditable = false }) { {timeline.map((event, index) => { const isRight = event.side === 'right'; - const isEditing = editingIndex === index; + const isEditing = editingProjectId === event.projectId; const card = ( { editFormRefs.current[index] = el; }} - onSaveEdit={(patch) => { - updateTimelineItem(index, patch); - setEditingIndex(null); - }} - onCancelEdit={() => setEditingIndex(null)} + onSaveEdit={(patch) => handleSaveEdit(index, patch)} + onCancelEdit={() => setEditingProjectId(null)} /> ); const controls = isAuthenticated && ( @@ -504,6 +623,7 @@ export default function TimelineSection({ isEditable = false }) { isEditing={isEditing} onEdit={() => handleEditButtonClick(index)} onDelete={() => handleDelete(index)} + disabled={isSaving} /> ); @@ -555,6 +675,7 @@ export default function TimelineSection({ isEditable = false }) { onSave={handleSaveNew} onCancel={() => setIsAdding(false)} showSaveButton + disabled={isSaving} />
diff --git a/src/stores/homeContentStore.js b/src/stores/homeContentStore.js index 867d5f2..691725b 100644 --- a/src/stores/homeContentStore.js +++ b/src/stores/homeContentStore.js @@ -1,12 +1,49 @@ 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) => ({ + cardId: card.cardId, + icon: ACTIVITY_LIST[index % ACTIVITY_LIST.length].icon, + title: card.title, + description: card.content, + })); +}; + +const mapProjectDetails = (projectDetails) => { + if (!Array.isArray(projectDetails)) return null; + if (projectDetails.length === 0) return []; + + return projectDetails.map((project, index) => ({ + projectId: project.projectId, + 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), + photoIds: (project.photos ?? []).map((photo) => photo.id), + })); +}; + /** * 메인페이지 콘텐츠(주요 활동/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 +53,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)),