From fba101500e6fa09afde21fa1b12bec9342d4115c Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 27 Apr 2026 07:12:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(travel):=20=EC=A7=80=EB=8F=84=20=ED=95=80?= =?UTF-8?q?=20=EB=A7=88=EC=BB=A4=20+=20=EC=9C=84=EC=B9=98=20=EC=A7=80?= =?UTF-8?q?=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20(Phase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MiniMap에 Point geometry 핀 마커 렌더링, 앨범 지역 변경 후 "위치 지정" 버튼으로 RegionPinPicker 모달을 열어 좌표 저장. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pages/travel/AlbumDetail.css | 28 +++++ src/pages/travel/AlbumDetail.jsx | 66 ++++++++-- src/pages/travel/MiniMap.jsx | 74 +++++++++-- src/pages/travel/RegionPinPicker.css | 180 +++++++++++++++++++++++++++ src/pages/travel/RegionPinPicker.jsx | 152 ++++++++++++++++++++++ 5 files changed, 479 insertions(+), 21 deletions(-) create mode 100644 src/pages/travel/RegionPinPicker.css create mode 100644 src/pages/travel/RegionPinPicker.jsx diff --git a/src/pages/travel/AlbumDetail.css b/src/pages/travel/AlbumDetail.css index 7fafbeb..52c3d68 100644 --- a/src/pages/travel/AlbumDetail.css +++ b/src/pages/travel/AlbumDetail.css @@ -106,6 +106,12 @@ opacity: 0.6; } +.album-detail__region-row { + display: flex; + align-items: center; + gap: 6px; +} + .album-detail__region-ok { color: #c8905e; } @@ -113,6 +119,28 @@ color: #dc5050; } +.album-detail__pin-btn { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 8px; + border-radius: 3px; + border: 1px solid var(--tv-line-bright, rgba(232, 221, 208, 0.22)); + background: transparent; + color: var(--tv-muted, rgba(232, 221, 208, 0.45)); + font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace); + font-size: 9px; + letter-spacing: 0.06em; + cursor: pointer; + transition: border-color 0.2s, color 0.2s, background 0.2s; + white-space: nowrap; +} +.album-detail__pin-btn:hover { + border-color: #c8905e; + color: #c8905e; + background: rgba(200, 144, 94, 0.08); +} + /* ── Region editor ── */ .album-detail__region-editor { position: relative; diff --git a/src/pages/travel/AlbumDetail.jsx b/src/pages/travel/AlbumDetail.jsx index 1fd1244..3d70e7a 100644 --- a/src/pages/travel/AlbumDetail.jsx +++ b/src/pages/travel/AlbumDetail.jsx @@ -4,6 +4,7 @@ import PullToRefresh from '../../components/PullToRefresh'; import MasonryGrid from './MasonryGrid'; import HeroLightbox from './HeroLightbox'; import VideoTab from './VideoTab'; +import RegionPinPicker from './RegionPinPicker'; import { getRegionAccent } from './MiniMap'; import { useIsMobile } from '../../hooks/useIsMobile'; import './AlbumDetail.css'; @@ -45,6 +46,8 @@ export default function AlbumDetail({ const [regionInput, setRegionInput] = useState(''); const [regionSaving, setRegionSaving] = useState(false); const [regionMsg, setRegionMsg] = useState(null); // { type: 'ok'|'err', text } + const [pinPickerOpen, setPinPickerOpen] = useState(false); + const [savedRegionId, setSavedRegionId] = useState(null); // region id after save const regionInputRef = useRef(null); const regionMsgTimer = useRef(null); @@ -92,6 +95,9 @@ export default function AlbumDetail({ }, ); if (!res.ok) throw new Error(`${res.status}`); + const data = await res.json(); + const newRegionId = data.new_region || name; + setSavedRegionId(newRegionId); setRegionMsg({ type: 'ok', text: `→ ${name}` }); setEditingRegion(false); onCoverChange?.(); // refresh album list @@ -113,6 +119,14 @@ export default function AlbumDetail({ } }, [handleRegionSave, handleRegionEditCancel]); + const handleOpenPinPicker = useCallback(() => { + setPinPickerOpen(true); + }, []); + + const handlePinSave = useCallback(() => { + onCoverChange?.(); // refresh regions + albums on map + }, [onCoverChange]); + // Enter → open useEffect(() => { if (prefersReduced()) { @@ -295,19 +309,33 @@ export default function AlbumDetail({ ) : ( - + + + {regionMsg?.type === 'ok' && savedRegionId && ( + + )} + )} @@ -320,6 +348,18 @@ export default function AlbumDetail({ + {/* Pin Picker */} + {pinPickerOpen && savedRegionId && ( + setPinPickerOpen(false)} + /> + )} + {/* Lightbox */} {selectedPhotoIndex != null && photos?.length > 0 && ( { + if (!geojson?.features) return { polygonGeoJson: geojson, pointFeatures: [] }; + const polys = []; + const points = []; + for (const f of geojson.features) { + if (f.geometry?.type === 'Point') { + points.push(f); + } else if (f.geometry) { + polys.push(f); + } + // null geometry → skip (no location yet) + } + return { + polygonGeoJson: { ...geojson, features: polys }, + pointFeatures: points, + }; + }, [geojson]); + const style = useCallback( (feature) => { const rid = feature.properties?.id || feature.properties?.name || ''; @@ -84,15 +103,54 @@ function MapLayer({ geojson, selectedRegionId, onSelectRegion }) { [map, onSelectRegion], ); + const handlePinClick = useCallback( + (feature) => { + const rid = feature.properties?.id || feature.properties?.name || ''; + onSelectRegion(rid); + const [lng, lat] = feature.geometry.coordinates; + map.setView([lat, lng], 5, { animate: true }); + }, + [map, onSelectRegion], + ); + if (!geojson) return null; return ( - + <> + {polygonGeoJson.features.length > 0 && ( + + )} + + {pointFeatures.map((feature) => { + const rid = feature.properties?.id || feature.properties?.name || ''; + const name = feature.properties?.name || rid; + const [lng, lat] = feature.geometry.coordinates; + const isSelected = + selectedRegionId && rid.toLowerCase() === selectedRegionId.toLowerCase(); + const accent = getRegionAccent(rid); + return ( + handlePinClick(feature) }} + > + {name} + + ); + })} + ); } diff --git a/src/pages/travel/RegionPinPicker.css b/src/pages/travel/RegionPinPicker.css new file mode 100644 index 0000000..93b244f --- /dev/null +++ b/src/pages/travel/RegionPinPicker.css @@ -0,0 +1,180 @@ +/* ── RegionPinPicker — modal overlay ── */ + +.pin-picker { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.7); + animation: pinPickerFadeIn 0.2s ease; +} + +@keyframes pinPickerFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.pin-picker__panel { + width: 90vw; + max-width: 560px; + background: var(--tv-surface, #1a1510); + border: 1px solid var(--tv-line-bright, rgba(232, 221, 208, 0.22)); + border-radius: 16px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5); +} + +/* Header */ +.pin-picker__header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.pin-picker__title { + display: flex; + align-items: baseline; + gap: 8px; +} + +.pin-picker__label { + font-family: var(--tv-mono, 'Space Mono', monospace); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--tv-muted, rgba(232, 221, 208, 0.45)); +} + +.pin-picker__region { + font-family: var(--tv-serif, 'Cormorant Garamond', Georgia, serif); + font-size: 20px; + font-weight: 600; +} + +.pin-picker__close { + width: 30px; + height: 30px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.08); + color: var(--tv-muted, rgba(232, 221, 208, 0.45)); + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; +} +.pin-picker__close:hover { + background: rgba(255, 255, 255, 0.16); +} + +/* Hint */ +.pin-picker__hint { + font-family: var(--tv-mono, 'Space Mono', monospace); + font-size: 11px; + color: var(--tv-dim, rgba(232, 221, 208, 0.25)); + letter-spacing: 0.06em; + margin: 0; +} + +/* Map */ +.pin-picker__map-wrap { + height: 320px; + border-radius: 10px; + overflow: hidden; + border: 1px solid var(--tv-line, rgba(232, 221, 208, 0.1)); +} + +.pin-picker__map { + background: var(--tv-bg, #0f0c09); +} + +.pin-picker__map .leaflet-tile-pane { + filter: brightness(0.7) saturate(0.4); +} + +/* Coords */ +.pin-picker__coords { + font-family: var(--tv-mono, 'Space Mono', monospace); + font-size: 11px; + color: var(--tv-muted, rgba(232, 221, 208, 0.45)); + letter-spacing: 0.04em; + margin: 0; + text-align: center; +} + +/* Error */ +.pin-picker__error { + font-family: var(--tv-mono, 'Space Mono', monospace); + font-size: 11px; + color: #dc5050; + margin: 0; + text-align: center; +} + +/* Actions */ +.pin-picker__actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.pin-picker__cancel, +.pin-picker__save { + padding: 8px 18px; + border-radius: 8px; + font-family: var(--tv-mono, 'Space Mono', monospace); + font-size: 12px; + letter-spacing: 0.04em; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; +} + +.pin-picker__cancel { + background: none; + border: 1px solid var(--tv-line-bright, rgba(232, 221, 208, 0.22)); + color: var(--tv-muted, rgba(232, 221, 208, 0.45)); +} +.pin-picker__cancel:hover { + border-color: var(--tv-text, #e8ddd0); + color: var(--tv-text, #e8ddd0); +} + +.pin-picker__save { + background: var(--accent, #c8905e); + border: none; + color: #0f0c09; + font-weight: 600; +} +.pin-picker__save:hover:not(:disabled) { + opacity: 0.85; +} +.pin-picker__save:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Mobile */ +@media (max-width: 480px) { + .pin-picker__panel { + width: 95vw; + padding: 16px; + } + + .pin-picker__map-wrap { + height: 260px; + } +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .pin-picker { + animation: none; + } +} diff --git a/src/pages/travel/RegionPinPicker.jsx b/src/pages/travel/RegionPinPicker.jsx new file mode 100644 index 0000000..dee2051 --- /dev/null +++ b/src/pages/travel/RegionPinPicker.jsx @@ -0,0 +1,152 @@ +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { MapContainer, TileLayer, CircleMarker, useMapEvents } from 'react-leaflet'; +import 'leaflet/dist/leaflet.css'; +import './RegionPinPicker.css'; + +/* ───────────────────────────────────────────── + ClickHandler — captures map clicks +───────────────────────────────────────────── */ +function ClickHandler({ onClickLatLng }) { + useMapEvents({ + click(e) { + onClickLatLng([e.latlng.lng, e.latlng.lat]); // [lng, lat] + }, + }); + return null; +} + +/* ───────────────────────────────────────────── + RegionPinPicker — modal for picking a + location on the map +───────────────────────────────────────────── */ +export default function RegionPinPicker({ + regionId, + regionName, + initialCoords, // [lng, lat] or null + accent, + onSave, // (coords: [lng, lat]) => void + onClose, +}) { + const [coords, setCoords] = useState(initialCoords || null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const backdropRef = useRef(null); + + /* ESC to close */ + useEffect(() => { + const handler = (e) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [onClose]); + + const handleMapClick = useCallback((lngLat) => { + setCoords(lngLat); + setError(''); + }, []); + + const handleSave = useCallback(async () => { + if (!coords) return; + setSaving(true); + setError(''); + try { + const res = await fetch(`/api/travel/regions/${encodeURIComponent(regionId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ coordinates: coords }), + }); + if (!res.ok) throw new Error(`${res.status}`); + onSave?.(coords); + onClose(); + } catch (e) { + setError(`저장 실패: ${e.message}`); + } finally { + setSaving(false); + } + }, [coords, regionId, onSave, onClose]); + + const handleBackdrop = useCallback( + (e) => { if (e.target === backdropRef.current) onClose(); }, + [onClose], + ); + + const center = coords + ? [coords[1], coords[0]] + : initialCoords + ? [initialCoords[1], initialCoords[0]] + : [30, 125]; + const zoom = coords || initialCoords ? 4 : 2; + + return ( +
+
+ {/* Header */} +
+
+ 위치 지정 + + {regionName || regionId} + +
+ +
+ + {/* Instruction */} +

지도를 클릭하여 핀을 놓으세요

+ + {/* Map */} +
+ + + + {coords && ( + + )} + +
+ + {/* Coords display */} + {coords && ( +

+ {coords[1].toFixed(4)}, {coords[0].toFixed(4)} +

+ )} + + {error &&

{error}

} + + {/* Actions */} +
+ + +
+
+
+ ); +}