diff --git a/src/api/my/updatePCName.js b/src/api/my/updatePCName.js new file mode 100644 index 0000000000000000000000000000000000000000..b2a4d9e6e5faf70381abf7f4a57f16fb787b3304 --- /dev/null +++ b/src/api/my/updatePCName.js @@ -0,0 +1,61 @@ +import axios from '../axios'; + +const updatePCName = async (pcId, newName) => { + try { + // 입력값 검증 + if (!newName || !newName.trim()) { + throw { + response: { + data: { + message: "잘못된 요청입니다", + statusCode: 400, + data: { + error: "이름은 필수 입력값입니다" + } + } + } + }; + } + + const response = await axios.patch(`/my/pc/${pcId}/name`, { + name: newName.trim() + }); + + // 성공 응답 형식에 맞춤 + return { + message: "PC 이름이 성공적으로 변경되었습니다", + statusCode: 200, + data: { + id: pcId, + name: newName, + updatedAt: new Date().toISOString() + } + }; + + } catch (error) { + // 401 Unauthorized 에러 처리 + if (error.response?.status === 401) { + throw { + message: "인증되지 않은 요청입니다", + statusCode: 401, + data: {} + }; + } + + // 400 Bad Request 에러 처리 + if (error.response?.status === 400) { + throw { + message: "잘못된 요청입니다", + statusCode: 400, + data: { + error: "이름은 필수 입력값입니다" + } + }; + } + + // 기타 에러는 그대로 전달 + throw error; + } +}; + +export default updatePCName; \ No newline at end of file diff --git a/src/mocks/handlers.js b/src/mocks/handlers.js index 753da329b1a7992afe3d2d858ff486c86ea02870..a5dae3a0b5ca5752c77c7580b76fb92bafefe8dc 100644 --- a/src/mocks/handlers.js +++ b/src/mocks/handlers.js @@ -481,6 +481,43 @@ export const handlers = [ return new HttpResponse(null, { status: 500 }); } }), + + // PC 이름 변경 + http.patch(`${api}/my/pc/:pcId/name`, async ({ request, params }) => { + const { name } = await request.json(); + + // 이름이 없거나 빈 문자열인 경우 + if (!name || !name.trim()) { + return HttpResponse.json({ + message: "잘못된 요청입니다", + statusCode: 400, + data: { + error: "이름은 필수 입력값입니다" + } + }, { status: 400 }); + } + + // Authorization 헤더가 없는 경우 + const authHeader = request.headers.get('Authorization'); + if (!authHeader) { + return HttpResponse.json({ + message: "인증되지 않은 요청입니다", + statusCode: 401, + data: {} + }, { status: 401 }); + } + + // 성공 응답 + return HttpResponse.json({ + message: "PC 이름이 성공적으로 변경되었습니다", + statusCode: 200, + data: { + id: params.pcId, + name: name.trim(), + updatedAt: new Date().toISOString() + } + }); + }), ]; // 부품 조회 함수 diff --git a/src/pages/MyCombinationPage/MyCombinationPage.css b/src/pages/MyCombinationPage/MyCombinationPage.css index fabfb213339c0d19cbe3c7315360413f43c979d9..ffc38c5b709ea2c1e777a3fb9bda10c813944f94 100644 --- a/src/pages/MyCombinationPage/MyCombinationPage.css +++ b/src/pages/MyCombinationPage/MyCombinationPage.css @@ -25,31 +25,127 @@ } .pc-item { + position: relative; + padding: 10px; + border-bottom: 1px solid #eee; + transition: all 0.3s ease; +} + +.pc-item.active { + background-color: var(--primary-light); + border-radius: 8px; + border-bottom: 1px solid var(--primary-light); +} + +.pc-item.active .pc-item-content span { + color: var(--primary-color); + font-weight: 500; +} + +.pc-item-content { + display: flex; + justify-content: space-between; + align-items: center; cursor: pointer; - padding: 1rem 1.25rem; + padding: 4px; +} + +.edit-name-container { + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px; +} + +.edit-name-container input { + padding: 8px 12px; + border: 1px solid var(--border-color); border-radius: 8px; - background-color: var(--background-light); - color: var(--text-primary); + font-size: 14px; transition: all 0.3s ease; - border: 1px solid transparent; +} + +.edit-name-container input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px var(--primary-light); +} + +.edit-buttons { + display: flex; + gap: 8px; +} + +.edit-buttons button { + padding: 6px 12px; + border-radius: 8px; + font-size: 14px; font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; } -.pc-item::before { - content: none; +.edit-buttons button:first-child { + background-color: var(--primary-color); + color: white; + border: 1px solid var(--primary-color); } -.pc-item:hover { - background-color: var(--background-light); - color: var(--primary-color); +.edit-buttons button:first-child:hover { + background-color: var(--primary-color); + box-shadow: 0 0 0 3px var(--primary-light); + transform: translateY(-1px); +} + +.edit-buttons button:first-child:disabled { + background-color: var(--primary-light); border-color: var(--primary-light); - transform: translateY(-2px); + cursor: not-allowed; + transform: none; + box-shadow: none; } -.pc-item.active { +.cancel-button { + background-color: var(--background-light); + border: 1px solid var(--border-color); + color: var(--text-primary); +} + +.cancel-button:hover { + background-color: var(--background-white); + border-color: var(--text-primary); + transform: translateY(-1px); +} + +.cancel-button:disabled { + background-color: var(--background-light); + border-color: var(--border-color); + color: var(--text-secondary); + cursor: not-allowed; + transform: none; +} + +.edit-button { + padding: 4px 12px; + font-size: 13px; background-color: var(--primary-color); + border: 1px solid var(--primary-color); + border-radius: 8px; color: white; - box-shadow: var(--shadow-sm); + cursor: pointer; + transition: all 0.3s ease; +} + +.edit-button:hover { + background-color: var(--primary-color); + box-shadow: 0 0 0 3px var(--primary-light); + transform: translateY(-1px); +} + +.error-message { + color: var(--error-color); + font-size: 12px; + margin-top: 4px; } .add-pc-btn { @@ -86,6 +182,12 @@ display: flex; flex-direction: column; gap: 1rem; + transition: opacity 0.3s ease; + opacity: 1; +} + +.part-list.loading { + opacity: 0.5; } .part-item { diff --git a/src/pages/MyCombinationPage/MyCombinationPage.jsx b/src/pages/MyCombinationPage/MyCombinationPage.jsx index b68356a07386c52cb22837774da8aa8585494af1..6f98c6cb5a50b92d23722592b7fb8036bf67e211 100644 --- a/src/pages/MyCombinationPage/MyCombinationPage.jsx +++ b/src/pages/MyCombinationPage/MyCombinationPage.jsx @@ -4,12 +4,18 @@ import getPartById from "@/api/parts/getPartById"; import { Link, useNavigate } from "react-router-dom"; import './MyCombinationPage.css'; import PartItem from "@/components/PartItem"; +import updatePCName from "@/api/my/updatePCName"; const CertifiedCombination = () => { const [pcs, setPcs] = useState([]); const [selectedPc, setSelectedPc] = useState(null); const [partsData, setPartsData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [editingPcId, setEditingPcId] = useState(null); + const [editingName, setEditingName] = useState(""); const navigate = useNavigate(); + const [isPartsLoading, setIsPartsLoading] = useState(false); useEffect(() => { const fetchPCs = async () => { @@ -30,6 +36,7 @@ const CertifiedCombination = () => { useEffect(() => { const fetchParts = async () => { if (selectedPc && !selectedPc.parts.some(part => part.partType === "오류")) { + setIsPartsLoading(true); const parts = await Promise.all( selectedPc.parts.map(async (partId) => { try { @@ -46,11 +53,13 @@ const CertifiedCombination = () => { }) ); setPartsData(parts.filter((part) => part !== null)); + setIsPartsLoading(false); } }; fetchParts(); }, [selectedPc]); + const handlePcClick = (pcId) => { const selected = pcs.find((pc) => pc.id === pcId); setSelectedPc(selected); @@ -60,6 +69,44 @@ const CertifiedCombination = () => { navigate('/partscertification'); }; + const handleEditClick = (pc) => { + setError(null); + setEditingPcId(pc.id); + setEditingName(pc.name); + }; + + const handleCancel = () => { + setEditingPcId(null); + setError(null); + }; + + const handleNameSubmit = async (pcId) => { + if (!editingName.trim()) { + setError("PC 이름을 입력해주세요"); + return; + } + + setIsLoading(true); + setError(null); + + try { + await updatePCName(pcId, editingName); + setPcs(pcs.map(pc => + pc.id === pcId ? { ...pc, name: editingName } : pc + )); + setEditingPcId(null); + } catch (error) { + setError("PC 이름 변경 중 오류가 발생했습니다"); + console.error("PC 이름 변경 중 오류 발생:", error); + } finally { + setIsLoading(false); + } + }; + + const handleNameChange = (e) => { + setEditingName(e.target.value); + }; + return ( <div className="layout"> <aside className="sidebar"> @@ -68,10 +115,54 @@ const CertifiedCombination = () => { {pcs.map((pc) => ( <li key={pc.id} - onClick={() => handlePcClick(pc.id)} className={`pc-item ${selectedPc && selectedPc.id === pc.id ? 'active' : ''}`} > - {pc.name} + {editingPcId === pc.id ? ( + <div className="edit-name-container"> + <input + type="text" + value={editingName} + onChange={handleNameChange} + onClick={(e) => e.stopPropagation()} + placeholder="PC 이름 입력" + /> + <div className="edit-buttons"> + <button + onClick={(e) => { + e.stopPropagation(); + handleNameSubmit(pc.id); + }} + disabled={isLoading} + > + {isLoading ? '저장 중...' : '저장'} + </button> + <button + onClick={(e) => { + e.stopPropagation(); + handleCancel(); + }} + className="cancel-button" + disabled={isLoading} + > + 취소 + </button> + </div> + {error && <div className="error-message">{error}</div>} + </div> + ) : ( + <div className="pc-item-content" onClick={() => handlePcClick(pc.id)}> + <span>{pc.name}</span> + <button + className="edit-button" + onClick={(e) => { + e.stopPropagation(); + handleEditClick(pc); + }} + > + 수정 + </button> + </div> + )} </li> ))} </ul> @@ -81,7 +172,7 @@ const CertifiedCombination = () => { </button> </aside> - <main className="part-list"> + <main className={`part-list ${isPartsLoading ? 'loading' : ''}`}> {partsData.map((part, index) => ( <PartItem key={index} part={part} /> ))} diff --git a/src/pages/SearchCombinationPage/components/CombinationBox.css b/src/pages/SearchCombinationPage/components/CombinationBox.css new file mode 100644 index 0000000000000000000000000000000000000000..b886b05ea6c85acc03d16518a5e3010c6ca87ddd --- /dev/null +++ b/src/pages/SearchCombinationPage/components/CombinationBox.css @@ -0,0 +1,41 @@ +.combination-box { + margin-bottom: 2rem; +} + +.combination-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.share-button { + padding: 0.5rem 1rem; + background-color: var(--primary-color); + color: white; + border: 1px solid var(--primary-color); + border-radius: 8px; + cursor: pointer; + font-weight: 500; + transition: all 0.3s ease; +} + +.share-button:hover { + background-color: var(--primary-color); + box-shadow: 0 0 0 3px var(--primary-light); + transform: translateY(-1px); +} + +.share-button:disabled { + background-color: var(--primary-light); + border-color: var(--primary-light); + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.title { + margin: 0; + font-size: 1.5rem; + color: #333; +} \ No newline at end of file diff --git a/src/pages/SearchCombinationPage/components/CombinationBox.jsx b/src/pages/SearchCombinationPage/components/CombinationBox.jsx index 27b45504267902259502fba2b0d291664be821a7..a078b3852d72f4484a1dd1bd0f8951ab4bbcab8a 100644 --- a/src/pages/SearchCombinationPage/components/CombinationBox.jsx +++ b/src/pages/SearchCombinationPage/components/CombinationBox.jsx @@ -1,3 +1,4 @@ +import "./CombinationBox.css"; import React, { useEffect, useState } from "react"; import CombinationGrid from "@/components/CombinationGrid/CombinationGrid"; import getPartById from "@/api/parts/getPartById"; @@ -39,9 +40,28 @@ const CombinationBox = ({ title, combination }) => { fetchPartDetails(); }, [combination]); + const handleShare = () => { + const partIds = combination.partids || combination.partIds; + const shareUrl = `${window.location.origin}/shared?parts=${partIds.join(',')}`; + + navigator.clipboard.writeText(shareUrl) + .then(() => { + alert('공유 링크가 클립보드에 복사되었습니다!'); + }) + .catch((err) => { + console.error('클립보드 복사 실패:', err); + alert('링크 복사에 실패했습니다.'); + }); + }; + return ( - <div> - <h1 className="title">{title}</h1> + <div className="combination-box"> + <div className="combination-header"> + <h1 className="title">{title}</h1> + <button className="share-button" onClick={handleShare}> + 공유하기 + </button> + </div> <CombinationGrid combination={partDetails} /> </div> ); diff --git a/src/pages/SharedCombinationPage/SharedCombinationPage.css b/src/pages/SharedCombinationPage/SharedCombinationPage.css new file mode 100644 index 0000000000000000000000000000000000000000..e4a4ac725ff86719951bc210b0234667f20f5d8e --- /dev/null +++ b/src/pages/SharedCombinationPage/SharedCombinationPage.css @@ -0,0 +1,48 @@ +.shared-combination-page { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; +} + +.shared-loading, +.shared-error { + text-align: center; + padding: 2rem; + font-size: 1.2rem; +} + +.shared-error { + color: #ff4444; +} + +.combination-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.share-button { + padding: 0.5rem 1rem; + background-color: var(--primary-color); + color: white; + border: 1px solid var(--primary-color); + border-radius: 8px; + cursor: pointer; + font-weight: 500; + transition: all 0.3s ease; +} + +.share-button:hover { + background-color: var(--primary-color); + box-shadow: 0 0 0 3px var(--primary-light); + transform: translateY(-1px); +} + +.share-button:disabled { + background-color: var(--primary-light); + border-color: var(--primary-light); + cursor: not-allowed; + transform: none; + box-shadow: none; +} \ No newline at end of file diff --git a/src/pages/SharedCombinationPage/SharedCombinationPage.jsx b/src/pages/SharedCombinationPage/SharedCombinationPage.jsx new file mode 100644 index 0000000000000000000000000000000000000000..906f5fd2353883f46d24632f03f2a51f2bb363ed --- /dev/null +++ b/src/pages/SharedCombinationPage/SharedCombinationPage.jsx @@ -0,0 +1,66 @@ +import React, { useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import CombinationBox from "../SearchCombinationPage/components/CombinationBox"; +import getCombination from "@/api/parts/getCombination"; +import "./SharedCombinationPage.css"; + +const SharedCombinationPage = () => { + const [searchParams] = useSearchParams(); + const [combination, setCombination] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchSharedCombination = async () => { + try { + const partsParam = searchParams.get('parts'); + if (!partsParam) { + throw new Error('부품 정보가 없습니다.'); + } + + const partIds = partsParam.split(','); + const data = await getCombination({ + cpuId: partIds[0], + gpuId: partIds[1], + mbId: partIds[2], + ramId: partIds[3], + ssdId: partIds[4], + hddId: partIds[5], + }); + + if (data && data.length > 0) { + setCombination(data[0]); + } else { + throw new Error('조합을 찾을 수 없습니다.'); + } + } catch (error) { + setError(error.message); + } finally { + setIsLoading(false); + } + }; + + fetchSharedCombination(); + }, [searchParams]); + + if (isLoading) { + return <div className="shared-loading">로딩 중...</div>; + } + + if (error) { + return <div className="shared-error">{error}</div>; + } + + return ( + <div className="shared-combination-page"> + {combination && ( + <CombinationBox + title="공유된 조합" + combination={combination} + /> + )} + </div> + ); +}; + +export default SharedCombinationPage; \ No newline at end of file