테스트 서버에 올리기 위한 준비

ID 중복 방지 - UUID v4 사용
This commit is contained in:
2026-08-20 17:09:43 +09:00
parent 2ed3c72b77
commit 3c458de80e
7 changed files with 336 additions and 76 deletions
+80
View File
@@ -377,6 +377,86 @@
margin-left: 4px;
}
.level-modal-backdrop {
position: absolute;
inset: 0;
background-color: rgba(0, 0, 0, 0.6);
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
}
.level-modal {
background: white;
border-radius: 16px;
padding: 20px;
width: min(320px, calc(100vw - 40px));
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.level-modal__img {
width: 100%;
max-height: 240px;
object-fit: contain;
border-radius: 8px;
background: #f3f4f6;
}
.level-modal__label {
font-size: 15px;
font-weight: 600;
color: #374151;
}
.level-modal__buttons {
display: flex;
gap: 12px;
width: 100%;
}
.level-modal__btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 14px 0;
border-radius: 10px;
border: 2px solid transparent;
font-size: 18px;
font-weight: 700;
cursor: pointer;
color: white;
transition: transform 0.1s;
}
.level-modal__btn:active {
transform: scale(0.95);
}
.level-modal__btn--A {
background-color: #22c55e;
}
.level-modal__btn--B {
background-color: #f97316;
}
.level-modal__btn--Pole {
background-color: #a855f7;
}
.level-modal__desc {
font-size: 11px;
font-weight: 500;
opacity: 0.85;
}
@media (min-width: 900px) {
.list-toggle,
.backdrop,
+147 -62
View File
@@ -14,7 +14,7 @@ import imageCompression from 'browser-image-compression';
import exifr from 'exifr';
import './App.css';
const VWORLD_API_KEY = '2C00322C-5037-37EB-A547-7001C13840E9';
const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY;
const LEVEL_COLORS = { A: '#22c55e', B: '#f97316', Pole: '#a855f7' };
const LEVEL_LABELS = { A: 'A', B: 'B', Pole: 'P' };
@@ -44,6 +44,8 @@ function App() {
const [listOpen, setListOpen] = useState(false);
const [uploadProgress, setUploadProgress] = useState(null);
const folderInputRef = useRef(null);
const levelResolveRef = useRef(null);
const [levelModal, setLevelModal] = useState(null);
const [levelFilter, setLevelFilter] = useState('all');
const [guFilter, setGuFilter] = useState('용산구');
const [dongFilter, setDongFilter] = useState('all');
@@ -66,8 +68,8 @@ function App() {
const filteredPoles = useMemo(() => {
return poles.filter(
(p) => (levelFilter === 'all' || p.level === levelFilter) &&
(guFilter === 'all' || p.dong.includes(guFilter)) &&
(dongFilter === 'all' || p.dong.includes(dongFilter))
(guFilter === 'all' || p.gu === guFilter) &&
(dongFilter === 'all' || p.dong === dongFilter)
);
}, [poles, levelFilter, guFilter, dongFilter]);
@@ -84,11 +86,11 @@ function App() {
if (f.get('poleId') !== 'current-location') source.removeFeature(f);
});
const idSet = new Set();
polesRef.current.forEach((pole) => {
poles.forEach((pole) => {
if (idSet.has(pole.id)) return;
if (levelFilter !== 'all' && pole.level !== levelFilter) return;
if (guFilter !== 'all' && !pole.dong.includes(guFilter)) return;
if (dongFilter !== 'all' && !pole.dong.includes(dongFilter)) return;
if (guFilter !== 'all' && pole.gu !== guFilter) return;
if (dongFilter !== 'all' && pole.dong !== dongFilter) return;
idSet.add(pole.id);
const feature = new Feature({
geometry: new Point(fromLonLat([pole.lng, pole.lat])),
@@ -112,51 +114,20 @@ function App() {
);
source.addFeature(feature);
});
}, [levelFilter, guFilter, dongFilter]);
}, [poles, levelFilter, guFilter, dongFilter]);
useEffect(() => {
syncMarkers();
}, [syncMarkers]);
const addMarker = useCallback((lng, lat, id, level) => {
if (!markerLayerRef.current) return;
const isCurrentLocation = id === 'current-location';
const color = isCurrentLocation ? '#3b82f6' : (LEVEL_COLORS[level] || '#ef4444');
const label = isCurrentLocation ? '' : (LEVEL_LABELS[level] || '전신주');
const feature = new Feature({
geometry: new Point(fromLonLat([lng, lat])),
poleId: id,
});
feature.setStyle(
new Style({
image: new CircleStyle({
radius: isCurrentLocation ? 6 : 8,
fill: new Fill({ color }),
stroke: new Stroke({ color: 'white', width: 2 }),
}),
text: new Text({
text: label,
offsetY: -20,
font: 'bold 12px sans-serif',
fill: new Fill({ color: '#1e293b' }),
stroke: new Stroke({ color: 'white', width: 3 }),
}),
}),
);
markerLayerRef.current.getSource().addFeature(feature);
if (isCurrentLocation) {
currentLocationRef.current = feature;
}
}, []);
const handleDelete = useCallback(async (id) => {
try {
const res = await fetch(`/api/poles/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`삭제 실패 (HTTP ${res.status})`);
setPoles((prev) => {
polesRef.current = prev.filter((p) => p.id !== id);
return prev.filter((p) => p.id !== id);
const next = prev.filter((p) => p.id !== id);
polesRef.current = next;
return next;
});
} catch (err) {
console.error('전신주 삭제 실패:', err);
@@ -183,6 +154,25 @@ function App() {
});
}, []);
const requestLevel = useCallback((file, gpsData) => {
const previewUrl = URL.createObjectURL(file);
return new Promise((resolve) => {
levelResolveRef.current = (level) => {
URL.revokeObjectURL(previewUrl);
resolve(level);
};
setLevelModal({ file, previewUrl, gpsData });
});
}, []);
const handleLevelSelect = useCallback((level) => {
setLevelModal(null);
if (levelResolveRef.current) {
levelResolveRef.current(level);
levelResolveRef.current = null;
}
}, []);
useEffect(() => {
if (mapRef.current) return;
@@ -234,7 +224,22 @@ function App() {
popupOverlay.setPosition(evt.coordinate);
});
addMarker(lng, lat, 'current-location');
const currentFeature = new Feature({
geometry: new Point(fromLonLat([lng, lat])),
poleId: 'current-location',
});
currentFeature.setStyle(
new Style({
image: new CircleStyle({
radius: 6,
fill: new Fill({ color: '#3b82f6' }),
stroke: new Stroke({ color: 'white', width: 2 }),
}),
text: new Text({ text: '' }),
}),
);
markerSource.addFeature(currentFeature);
currentLocationRef.current = currentFeature;
Promise.allSettled([
fetch('/api/poles').then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }),
fetch('/api/all-dongs').then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }),
@@ -322,7 +327,7 @@ function App() {
currentLocationRef.current = null;
fallbackUsedRef.current = false;
};
}, [addMarker]);
}, []);
const handleCapture = async (e) => {
const file = e.target.files?.[0];
@@ -334,9 +339,9 @@ function App() {
try {
let lat;
let lng;
let exifTimestamp = null;
let source = '';
// 1차: 사진 EXIF에서 GPS 추출 (ArrayBuffer로 읽어 Android 호환성 확보)
try {
const buffer = await file.arrayBuffer();
console.log('파일 타입:', file.type, '크기:', file.size, 'buffer 크기:', buffer.byteLength);
@@ -347,14 +352,16 @@ function App() {
lng = exifData.longitude;
source = 'EXIF';
lastPositionRef.current = { lat, lng };
} else {
console.log('EXIF에 GPS 데이터 없음');
}
if (exifData && exifData.DateTimeOriginal) {
exifTimestamp = new Date(exifData.DateTimeOriginal).toISOString();
} else if (exifData && exifData.DateTime) {
exifTimestamp = new Date(exifData.DateTime).toISOString();
}
} catch (err) {
console.error('EXIF 읽기 실패:', err);
}
// 2차: 브라우저 GPS (EXIF에 GPS가 없는 경우)
if (lat === undefined && navigator.geolocation) {
try {
console.log('브라우저 GPS 요청 중...');
@@ -374,7 +381,6 @@ function App() {
}
}
// 3차: 마지막 알려진 위치 (watchPosition 또는 초기 위치)
if (lat === undefined) {
const last = lastPositionRef.current;
if (last) {
@@ -388,27 +394,45 @@ function App() {
setGpsInfo({ source, lat, lng });
const level = await requestLevel(file, { lat, lng });
if (!level) {
setProcessing(false);
e.target.value = '';
return;
}
const compressed = await imageCompression(file, {
maxSizeMB: 0.5,
maxWidthOrHeight: 800,
useWebWorker: true,
});
const id = `pole-${Date.now()}`;
const id = `pole-${crypto.randomUUID()}`;
const formData = new FormData();
formData.append('level', level);
formData.append('photo', compressed, `${id}.jpg`);
formData.append('id', id);
formData.append('lat', lat);
formData.append('lng', lng);
formData.append('timestamp', new Date().toISOString());
formData.append('timestamp', exifTimestamp || new Date().toISOString());
const res = await fetch('/api/poles', { method: 'POST', body: formData });
if (!res.ok) throw new Error(`등록 실패 (HTTP ${res.status})`);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
if (res.status === 409) {
alert('이미 등록된 전신주입니다 (GPS 좌표 + 촬영 시간 중복).');
setProcessing(false);
e.target.value = '';
return;
}
throw new Error(body.error || `등록 실패 (HTTP ${res.status})`);
}
const pole = await res.json();
setPoles((prev) => {
polesRef.current = [...prev, pole];
return [...prev, pole];
const next = [...prev, pole];
polesRef.current = next;
return next;
});
} catch (err) {
console.error('전신주 등록 실패:', err);
@@ -436,6 +460,7 @@ function App() {
let registered = 0;
let skipped = 0;
let duplicated = 0;
for (let i = 0; i < imageFiles.length; i++) {
const file = imageFiles[i];
@@ -444,6 +469,7 @@ function App() {
try {
let lat;
let lng;
let exifTimestamp = null;
try {
const buffer = await file.arrayBuffer();
@@ -453,6 +479,11 @@ function App() {
lng = exifData.longitude;
lastPositionRef.current = { lat, lng };
}
if (exifData && exifData.DateTimeOriginal) {
exifTimestamp = new Date(exifData.DateTimeOriginal).toISOString();
} else if (exifData && exifData.DateTime) {
exifTimestamp = new Date(exifData.DateTime).toISOString();
}
} catch (err) {
console.error('EXIF 읽기 실패:', file.name, err);
}
@@ -462,27 +493,41 @@ function App() {
continue;
}
const level = await requestLevel(file, { lat, lng });
if (!level) {
skipped++;
continue;
}
const compressed = await imageCompression(file, {
maxSizeMB: 0.5,
maxWidthOrHeight: 800,
useWebWorker: true,
});
const id = `pole-${Date.now()}-${i}`;
const id = `pole-${crypto.randomUUID()}-${i}`;
const formData = new FormData();
formData.append('level', level);
formData.append('photo', compressed, `${id}.jpg`);
formData.append('id', id);
formData.append('lat', lat);
formData.append('lng', lng);
formData.append('timestamp', new Date().toISOString());
formData.append('timestamp', exifTimestamp || new Date().toISOString());
const res = await fetch('/api/poles', { method: 'POST', body: formData });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.ok) {
if (res.status === 409) {
duplicated++;
continue;
}
throw new Error(`HTTP ${res.status}`);
}
const pole = await res.json();
setPoles((prev) => {
polesRef.current = [...prev, pole];
return [...prev, pole];
const next = [...prev, pole];
polesRef.current = next;
return next;
});
registered++;
} catch (err) {
@@ -493,7 +538,10 @@ function App() {
setUploadProgress(null);
setProcessing(false);
alert(`등록 완료: ${registered}장${skipped > 0 ? ` / 건너뜀: ${skipped}장 (GPS 없음)` : ''}`);
const parts = [`등록: ${registered}장`];
if (duplicated > 0) parts.push(`중복: ${duplicated}장`);
if (skipped > 0) parts.push(`건너뜀: ${skipped}장`);
alert(parts.join(' / '));
e.target.value = '';
};
@@ -572,6 +620,41 @@ function App() {
</div>
)}
{levelModal && (
<div className="level-modal-backdrop" onClick={() => handleLevelSelect(null)}>
<div className="level-modal" onClick={(e) => e.stopPropagation()}>
<img src={levelModal.previewUrl} alt="전신주 사진" className="level-modal__img" />
<div className="level-modal__label">전신주 등급을 선택하세요</div>
<div className="level-modal__buttons">
<button
type="button"
className="level-modal__btn level-modal__btn--A"
onClick={() => handleLevelSelect('A')}
>
A
<span className="level-modal__desc">양호</span>
</button>
<button
type="button"
className="level-modal__btn level-modal__btn--B"
onClick={() => handleLevelSelect('B')}
>
B
<span className="level-modal__desc">불량</span>
</button>
<button
type="button"
className="level-modal__btn level-modal__btn--Pole"
onClick={() => handleLevelSelect('Pole')}
>
Pole
<span className="level-modal__desc">기준</span>
</button>
</div>
</div>
</div>
)}
{poles.length > 0 && (
<button
type="button"
@@ -599,7 +682,9 @@ function App() {
<div
className="backdrop"
onClick={() => setListOpen(false)}
onKeyDown={() => setListOpen(false)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') setListOpen(false);
}}
role="button"
tabIndex={0}
/>
@@ -660,7 +745,7 @@ function App() {
>
<option value="all">전체 구</option>
{guList.map((gu) => {
const count = poles.filter((p) => p.dong.includes(gu)).length;
const count = poles.filter((p) => p.gu === gu).length;
return (
<option key={gu} value={gu}>{gu} ({count}기)</option>
);
@@ -674,7 +759,7 @@ function App() {
>
<option value="all">전체 동</option>
{dongList.map((d) => {
const count = poles.filter((p) => p.dong.includes(d.dong)).length;
const count = poles.filter((p) => p.gu === guFilter && p.dong === d.dong).length;
return (
<option key={d.dong} value={d.dong}>{d.dong} ({count}기)</option>
);