diff --git a/src/components/MapSearch.js b/src/components/MapSearch.js index e25e4215091839ddfde5bdc63aea21d4c30cfebf..d8436b4a2db9fef7188b6bf851b3e4220a0240be 100644 --- a/src/components/MapSearch.js +++ b/src/components/MapSearch.js @@ -76,7 +76,8 @@ const MapSearch = ({ map, onSearchResult, clearMap, setMarkers, trips }) => { const newPlace = { id: place.id, name: place.name, - location: place.address, + address: place.address, + type: 77, coordinates: place.coordinates, tel: place.phone }; diff --git a/src/components/PlaceDetails.js b/src/components/PlaceDetails.js index 6dacb7020716579ae03603f8989059d0bcd14c16..864ef4eb33ee296079d1419b982065eae3077ba3 100644 --- a/src/components/PlaceDetails.js +++ b/src/components/PlaceDetails.js @@ -3,6 +3,7 @@ import axios from 'axios'; import { FaMapMarkerAlt, FaPhoneAlt, FaCalendarAlt, FaClock, FaParking, FaMoneyBillWave } from 'react-icons/fa'; // 필요한 아이콘 가져오기 import '../styles/PlaceDetails.css'; import PlaceReview from './PlaceReview'; +import ShimmerImage from './ShimmerImage'; const PlaceDetails = ({ place, onClose }) => { const [placeDetails, setPlaceDetails] = useState(null); @@ -56,6 +57,19 @@ const PlaceDetails = ({ place, onClose }) => { }; }, [onClose]); + // HTML 문자열을 안전하게 렌더링하는 함수 + const renderHTML = (htmlString) => { + if (!htmlString) return null; + + // <br> 태그를 줄바꿈으로 변환 + return htmlString.split('<br>').map((text, index, array) => ( + <React.Fragment key={index}> + {text} + {index < array.length - 1 && <br />} + </React.Fragment> + )); + }; + if (loading) { return <p>로딩 중...</p>; } @@ -68,7 +82,7 @@ const PlaceDetails = ({ place, onClose }) => { <div className="place-detail" ref={detailsRef}> <h2>{place.name}</h2> {place.image && ( - <img + <ShimmerImage src={place.image} alt={place.name} className="detail-image" @@ -76,40 +90,68 @@ const PlaceDetails = ({ place, onClose }) => { )} <div className="detail-info"> <p className="location"> - <FaMapMarkerAlt className="icon" /> <strong>주소:</strong> {place.address} + <FaMapMarkerAlt className="icon" /> + <strong>주소:</strong> + <span>{renderHTML(place.location || place.address)}</span> </p> {place.tel && ( <p className="tel"> - <FaPhoneAlt className="icon" /> <strong>전화:</strong> {place.tel} + <FaPhoneAlt className="icon" /> + <strong>전화:</strong> + <span>{renderHTML(place.tel)}</span> </p> )} - {/* 관광지일 경우 - 문자열 대신 숫자로 비교 */} + {/* 관광지일 경우 */} {(place.type === 12 || place.type === '12') && ( <> {placeDetails.restdate && ( - <p><FaCalendarAlt className="icon" /> <strong>쉬는날:</strong> {placeDetails.restdate}</p> + <p> + <FaCalendarAlt className="icon" /> + <strong>쉬는날:</strong> + <span>{renderHTML(placeDetails.restdate)}</span> + </p> )} {placeDetails.usetime && ( - <p><FaClock className="icon" /> <strong>이용시간:</strong> {placeDetails.usetime}</p> + <p> + <FaClock className="icon" /> + <strong>이용시간:</strong> + <span>{renderHTML(placeDetails.usetime)}</span> + </p> )} {placeDetails.parking && ( - <p><FaParking className="icon" /> <strong>주차장:</strong> {placeDetails.parking}</p> + <p> + <FaParking className="icon" /> + <strong>주차장:</strong> + <span>{renderHTML(placeDetails.parking)}</span> + </p> )} </> )} - {/* 지역 축제일 경우 - 문자열 대신 숫자로 비교 */} + {/* 지역 축제일 경우 */} {(place.type === 15 || place.type === '15') && ( <> {placeDetails.eventstartdate && placeDetails.eventenddate && ( - <p><FaCalendarAlt className="icon" /> <strong>기간:</strong> {`${placeDetails.eventstartdate} ~ ${placeDetails.eventenddate}`}</p> + <p> + <FaCalendarAlt className="icon" /> + <strong>기간:</strong> + <span>{`${placeDetails.eventstartdate} ~ ${placeDetails.eventenddate}`}</span> + </p> )} {placeDetails.sponsor1 && ( - <p><FaPhoneAlt className="icon" /> <strong>주최자:</strong> {placeDetails.sponsor1}</p> + <p> + <FaPhoneAlt className="icon" /> + <strong>주최자:</strong> + <span>{renderHTML(placeDetails.sponsor1)}</span> + </p> )} {placeDetails.usetimefestival && ( - <p><FaMoneyBillWave className="icon" /> <strong>이용 요금:</strong> {placeDetails.usetimefestival}</p> + <p> + <FaMoneyBillWave className="icon" /> + <strong>이용 요금:</strong> + <span>{renderHTML(placeDetails.usetimefestival)}</span> + </p> )} </> )} diff --git a/src/components/ShimmerImage.js b/src/components/ShimmerImage.js new file mode 100644 index 0000000000000000000000000000000000000000..8c714ed416bbd2fdbc74b38e266d53aa649a4277 --- /dev/null +++ b/src/components/ShimmerImage.js @@ -0,0 +1,24 @@ +import React, { useState } from 'react'; +import '../styles/ShimmerImage.css'; + +const ShimmerImage = ({ src, alt, className }) => { + const [isLoaded, setIsLoaded] = useState(false); + + const handleImageLoad = () => { + setIsLoaded(true); + }; + + return ( + <div className="shimmer-wrapper"> + {!isLoaded && <div className="shimmer" />} + <img + src={src} + alt={alt} + className={`shimmer-image ${isLoaded ? 'loaded' : ''} ${className || ''}`} + onLoad={handleImageLoad} + /> + </div> + ); +}; + +export default ShimmerImage; \ No newline at end of file diff --git a/src/styles/PlaceDetails.css b/src/styles/PlaceDetails.css index 47bd2319dcd7fd7f56710f68f6a3d4d30641638c..eb9cb4a80e8503d7c16b0284be8ba1246a3791a2 100644 --- a/src/styles/PlaceDetails.css +++ b/src/styles/PlaceDetails.css @@ -2,6 +2,7 @@ font-weight: bold; font-size: 1.25rem; margin-bottom: 1rem; + word-break: keep-all; } .detail-image { @@ -12,17 +13,33 @@ .detail-info { margin-top: 20px; + width: 100%; } .detail-info p { display: flex; - align-items: center; + align-items: flex-start; font-size: 16px; margin-bottom: 10px; + line-height: 1.5; + word-break: keep-all; } .icon { margin-right: 8px; + min-width: 16px; + margin-top: 4px; + color: var(--primary-color); +} + +.detail-info p strong { + margin-right: 8px; + white-space: nowrap; +} + +.detail-info p span { + flex: 1; + word-break: break-all; } .location, @@ -35,12 +52,20 @@ .sponsor1, .usetimefestival { font-size: 16px; -} - -.icon { - color: var(--primary-color); + width: 100%; } h2 { font-size: 24px; +} + +@media screen and (max-width: 480px) { + .detail-info p { + flex-wrap: wrap; + gap: 4px; + } + + .detail-info p strong { + min-width: 60px; + } } \ No newline at end of file diff --git a/src/styles/PlaceReview.css b/src/styles/PlaceReview.css index 6c39428f341339629fbb73c3fdb85faed54aac00..10612c48a1dbae70ccdaf5c8ceaf1548aa283c88 100644 --- a/src/styles/PlaceReview.css +++ b/src/styles/PlaceReview.css @@ -1,3 +1,15 @@ +.place-reviews { + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid #eee; +} + +.place-reviews .title { + font-size: 1.2rem; + font-weight: bold; + margin-bottom: 24px; +} + .review-form { background: #f8f9fa; padding: 1rem; @@ -85,7 +97,7 @@ } .reviews-list { - margin-top: 25px; + margin-top: 30px; } .review-item { diff --git a/src/styles/ShimmerImage.css b/src/styles/ShimmerImage.css new file mode 100644 index 0000000000000000000000000000000000000000..0bf50835986ba2a213a3681387b86c19d82bd12d --- /dev/null +++ b/src/styles/ShimmerImage.css @@ -0,0 +1,43 @@ +.shimmer-wrapper { + position: relative; + width: 100%; + height: auto; + background: #f6f7f8; + overflow: hidden; +} + +.shimmer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.2) 50%, + rgba(255, 255, 255, 0) 100% + ); + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +.shimmer-image { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 0.3s ease-in-out; +} + +.shimmer-image.loaded { + opacity: 1; +} \ No newline at end of file