upload 기능 구현 전에 일단 커밋

This commit is contained in:
2026-08-20 16:00:15 +09:00
commit 2ed3c72b77
24 changed files with 4959 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
VITE_VWORLD_API_KEY=
VWORLD_DOMAIN=localhost
PORT=4000
SUPABASE_URL=
SUPABASE_SERVICE_ROLE_KEY=
+33
View File
@@ -0,0 +1,33 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Environment
.env
.env.local
.env.*.local
# Server data
server/data/
server/uploads/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+42
View File
@@ -0,0 +1,42 @@
# Pole Mapper
전신주 위치 등록용 웹앱. React + Vite(프론트) + Express + Supabase(백엔드), OpenLayers 지도, VWorld API 기반.
## 명령어
- `npm run dev` — 클라이언트(vite) + 서버(`node --watch server/index.js`) 동시 실행
- `npm run dev:client` — 프론트만
- `npm run dev:server` — 백엔드만
- `npm run build` — 프로덕션 빌드
- `npm run lint` — oxlint
## 구조
- `src/App.jsx` — 전체 UI + 지도 로직이 한 파일에 있음 (지도, GPS, 사진 등록, 목록). 서버 `/api/poles` API 호출
- `server/index.js` — Express REST API (`/api/gus`, `/api/dongs`, `/api/poles` CRUD, `/uploads` 정적 서빙)
- `server/db.js` — @supabase/supabase-js 클라이언트 (gus/dongs/poles 테이블, 전부 async)
- `server/vworld.js` — VWorld `GetFeature` 조회, 페이지네이션(최대 20페이지)
- `supabase-schema.sql` — Supabase SQL Editor에서 실행할 스키마
- `vite.config.js` — HTTPS 필수 (`~/.vite-key.pem`, `~/.vite-cert.pem` 요구), `/api`, `/uploads` 프록시 → :4000
## 환경 설정 (.env)
`VWORLD_API_KEY`, `VWORLD_DOMAIN=localhost`, `PORT=4000`, `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY` (서버용 `.env`)
## 현재 상태 (진행 중인 작업)
- 데이터베이스는 Supabase (Postgres) 사용. 기존 better-sqlite3는 제거됨.
- 스키마는 `supabase-schema.sql`을 SQL Editor에서 실행해야 함
- 서버에서 `SUPABASE_SERVICE_ROLE_KEY`를 사용 (서버가 곧 신뢰 경계)
- 클라이언트는 `/api/poles` API로 CRUD (사진은 multer로 `server/uploads/`에 저장, base64/loccalStorage 제거됨)
- `src/App.jsx:16`에 VWorld API 키가 하드코딩됨 → `import.meta.env.VITE_VWORLD_API_KEY`로 교체 필요 (키는 커밋하지 말 것)
- 위치 결정 우선순위: EXIF GPS → 브라우저 GPS → 마지막 알려진 위치
- dev용 VWorld 키는 `VITE_VWORLD_API_KEY`로도 넘길 수 있음 (`server/vworld.js`에서 서버 키에 대해 폴백)
## 주의사항
- geolocation/카메라 때문에 dev 서버는 HTTPS가 반드시 필요함
- `/api/poles` POST는 multer(`photo` 필드)로 사진을 `server/uploads/`에 저장
- 구/동 데이터는 VWorld에서 24시간 TTL 캐시 (Supabase gus/dongs 테이블의 updated_at으로 판단)
- Supabase 테이블에 RLS가 기본 활성화되어 있으면 서비스 롤 키는 통과하지만, anon 키로 직접 접근은 RLS 정책 필요
- Git 저장소 아님
+3
View File
@@ -0,0 +1,3 @@
supabase
DBPW : TnvkEngineSori1!
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Pole Mapper</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@latest/ol.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+3078
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "pole-mapper",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "concurrently -k \"npm:dev:client\" \"npm:dev:server\"",
"dev:client": "vite",
"dev:server": "node --watch server/index.js",
"build": "vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.112.3",
"browser-image-compression": "^2.0.2",
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"exifr": "^7.1.3",
"express": "^5.1.0",
"multer": "^2.0.2",
"ol": "^10.9.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"concurrently": "^9.2.0",
"oxlint": "^1.71.0",
"vite": "^8.1.1"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+123
View File
@@ -0,0 +1,123 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseKey) {
throw new Error('SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY 환경변수가 필요합니다.');
}
const supabase = createClient(supabaseUrl, supabaseKey);
const nowIso = () => new Date().toISOString();
export async function isGusFresh(ttlMs) {
const { data, error } = await supabase
.from('gus')
.select('updated_at')
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
return !!data?.updated_at && Date.now() - Date.parse(data.updated_at) < ttlMs;
}
export async function replaceGus(entries) {
const now = nowIso();
const rows = entries.map(({ code, name }) => ({ code, name, updated_at: now }));
const { error: delErr } = await supabase.from('gus').delete().neq('code', '');
if (delErr) throw delErr;
const { error } = await supabase.from('gus').insert(rows);
if (error) throw error;
}
export async function getGus() {
const { data, error } = await supabase.from('gus').select('code, name').order('code');
if (error) throw error;
return data || [];
}
export async function getGuByName(name) {
const { data, error } = await supabase
.from('gus')
.select('code, name')
.eq('name', name)
.maybeSingle();
if (error) throw error;
return data;
}
export async function isDongsFresh(guName, ttlMs) {
const { data, error } = await supabase
.from('dongs')
.select('updated_at')
.eq('gu_name', guName)
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
return !!data?.updated_at && Date.now() - Date.parse(data.updated_at) < ttlMs;
}
export async function replaceDongs(guName, dongList) {
const now = nowIso();
const rows = dongList.map((dong) => ({ gu_name: guName, dong, updated_at: now }));
const { error: delErr } = await supabase.from('dongs').delete().eq('gu_name', guName);
if (delErr) throw delErr;
const { error } = await supabase.from('dongs').insert(rows);
if (error) throw error;
}
export async function getDongs(guName) {
const { data, error } = await supabase
.from('dongs')
.select('dong')
.eq('gu_name', guName)
.order('dong');
if (error) throw error;
return (data || []).map((r) => r.dong);
}
export async function insertPole(pole) {
const { error } = await supabase.from('poles').insert(pole);
if (error) throw error;
}
export async function getPole(id) {
const { data, error } = await supabase
.from('poles')
.select('*')
.eq('id', id)
.maybeSingle();
if (error) throw error;
return data;
}
export async function getAllPoles({ gu = '', dong = '', start = '', end = '' } = {}) {
let query = supabase.from('poles').select('*').order('timestamp', { ascending: false });
if (gu) query = query.eq('gu', gu);
if (dong) query = query.eq('dong', dong);
if (start) query = query.gte('timestamp', start);
if (end) query = query.lte('timestamp', end);
const { data, error } = await query;
if (error) throw error;
return (data || []).map((p) => ({
...p,
photoUrl: p.photo_path ? `/uploads/${p.photo_path}` : null,
}));
}
export async function deletePole(id) {
const { error } = await supabase.from('poles').delete().eq('id', id);
if (error) throw error;
}
export async function getAllDongs() {
const { data, error } = await supabase
.from('dongs')
.select('gu_name, dong')
.order('gu_name')
.order('dong');
if (error) throw error;
return data || [];
}
+133
View File
@@ -0,0 +1,133 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import multer from 'multer';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import * as db from './db.js';
import { fetchGus, fetchDongs } from './vworld.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const UPLOAD_DIR = path.join(__dirname, 'uploads');
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const PORT = Number(process.env.PORT || 4000);
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const app = express();
app.use(cors());
app.use(express.json());
app.use('/uploads', express.static(UPLOAD_DIR));
const upload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
filename: (req, file, cb) => {
const id = req.body?.id || `pole-${Date.now()}`;
const ext = path.extname(file.originalname) || '.jpg';
cb(null, `${id}${ext}`);
},
}),
limits: { fileSize: 5 * 1024 * 1024 },
});
app.get('/api/health', (_req, res) => res.json({ status: 'ok' }));
app.get('/api/gus', async (_req, res) => {
try {
if (!(await db.isGusFresh(CACHE_TTL_MS))) {
await db.replaceGus(await fetchGus());
}
res.json(await db.getGus());
} catch (e) {
res.status(502).json({ error: e.message });
}
});
app.get('/api/dongs', async (req, res) => {
const { gu } = req.query;
if (!gu) return res.status(400).json({ error: 'gu 파라미터가 필요합니다.' });
try {
if (!(await db.isDongsFresh(gu, CACHE_TTL_MS))) {
const guRow = await db.getGuByName(gu);
if (!guRow) return res.status(404).json({ error: `'${gu}' 구를 찾을 수 없습니다.` });
await db.replaceDongs(gu, await fetchDongs(guRow.code));
}
res.json(await db.getDongs(gu));
} catch (e) {
res.status(502).json({ error: e.message });
}
});
app.get('/api/poles', async (req, res) => {
const { gu = '', dong = '', start = '', end = '' } = req.query;
try {
res.json(await db.getAllPoles({ gu, dong, start, end }));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.post('/api/poles', upload.single('photo'), async (req, res) => {
const { id, lat, lng, gu = '', dong = '', timestamp, level = '' } = req.body ?? {};
if (lat === undefined || lng === undefined || !timestamp) {
return res.status(400).json({ error: 'lat, lng, timestamp는 필수입니다.' });
}
const pole = {
id: id || `pole-${Date.now()}`,
lat: Number(lat),
lng: Number(lng),
gu,
dong,
timestamp,
level,
photo_path: req.file ? req.file.filename : null,
};
try {
await db.insertPole(pole);
} catch (e) {
return res.status(500).json({ error: e.message });
}
res.status(201).json({
...pole,
photo_path: undefined,
photoUrl: pole.photo_path ? `/uploads/${pole.photo_path}` : null,
});
});
app.get('/api/all-dongs', async (_req, res) => {
try {
if (!(await db.isGusFresh(CACHE_TTL_MS))) {
await db.replaceGus(await fetchGus());
}
const gus = await db.getGus();
for (const gu of gus) {
if (!(await db.isDongsFresh(gu.name, CACHE_TTL_MS))) {
await db.replaceDongs(gu.name, await fetchDongs(gu.code));
}
}
const allDongs = await db.getAllDongs();
res.json(allDongs);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.delete('/api/poles/:id', async (req, res) => {
const existing = await db.getPole(req.params.id);
if (!existing) return res.status(404).json({ error: '전신주를 찾을 수 없습니다.' });
if (existing.photo_path) {
fs.rm(path.join(UPLOAD_DIR, path.basename(existing.photo_path)), () => {});
}
try {
await db.deletePole(existing.id);
} catch (e) {
return res.status(500).json({ error: e.message });
}
res.json({ removed: true });
});
app.listen(PORT, () => {
console.log(`Pole Mapper API server: http://localhost:${PORT}`);
});
+149
View File
@@ -0,0 +1,149 @@
import 'dotenv/config';
import { createClient } from '@supabase/supabase-js';
import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { join, basename } from 'path';
const SOURCE_URL = 'https://saftyovhcable.kr/addMarker.html';
const THUMB_BASE = 'https://saftyovhcable.kr/res/Thumb/';
const UPLOAD_DIR = join(import.meta.dirname, 'uploads');
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseKey) {
console.error('SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY 환경변수가 필요합니다.');
process.exit(1);
}
const supabase = createClient(supabaseUrl, supabaseKey);
async function fetchPageCSV() {
console.log('1) 페이지에서 CSV 데이터를 가져오는 중...');
const res = await fetch(SOURCE_URL);
if (!res.ok) throw new Error(`페이지 요청 실패: ${res.status}`);
const html = await res.text();
const match = html.match(/<textarea[^>]*id="csvInput"[^>]*>([\s\S]*?)<\/textarea>/i);
if (!match) throw new Error('csvInput textarea를 찾을 수 없습니다.');
const csvText = match[1].trim();
const lines = csvText.split('\n').map((l) => l.trim()).filter(Boolean);
const header = lines[0];
const dataLines = lines.slice(1);
console.log(` - 헤더: ${header}`);
console.log(` - 데이터 행 수: ${dataLines.length}`);
return { header, dataLines };
}
function parseCSVLines(header, dataLines) {
console.log('2) CSV 파싱 중...');
const fields = header.split('|');
const rows = [];
for (const line of dataLines) {
const cols = line.split('|');
if (cols.length < fields.length) continue;
const obj = {};
for (let i = 0; i < fields.length; i++) {
obj[fields[i]] = (cols[i] || '').trim();
}
rows.push(obj);
}
const valid = rows.filter(
(r) => r.Path && r.Path.endsWith('.jpg') && parseFloat(r.GPS_lat) !== 0 && parseFloat(r.GPS_lon) !== 0
);
console.log(` - 전체 파싱: ${rows.length}행, 유효(GPS+사진): ${valid.length}행`);
return { rows, valid };
}
async function downloadThumbs(validRows) {
console.log('3) Thumb 이미지 다운로드 중...');
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
let downloaded = 0;
let skipped = 0;
let failed = 0;
const BATCH = 10;
for (let i = 0; i < validRows.length; i += BATCH) {
const batch = validRows.slice(i, i + BATCH);
const results = await Promise.allSettled(
batch.map(async (row) => {
const fileName = basename(row.Path);
const dest = join(UPLOAD_DIR, fileName);
if (existsSync(dest)) {
skipped++;
return;
}
const url = THUMB_BASE + encodeURIComponent(fileName);
const resp = await fetch(url);
if (!resp.ok) {
console.warn(` ⚠ 다운로드 실패: ${fileName} (${resp.status})`);
failed++;
return;
}
const buffer = Buffer.from(await resp.arrayBuffer());
writeFileSync(dest, buffer);
downloaded++;
})
);
results.forEach((r) => {
if (r.status === 'rejected') {
console.warn(` ⚠ 에러:`, r.reason?.message);
failed++;
}
});
if ((i + BATCH) % 100 === 0 || i + BATCH >= validRows.length) {
console.log(` 진행: ${Math.min(i + BATCH, validRows.length)}/${validRows.length} (다운:${downloaded} 스킵:${skipped} 실패:${failed})`);
}
}
console.log(` 완료 - 다운:${downloaded} 스킵:${skipped} 실패:${failed}`);
}
async function insertToDB(validRows) {
console.log('4) Supabase poles 테이블에 데이터 삽입 중...');
const BATCH = 50;
let inserted = 0;
let failed = 0;
for (let i = 0; i < validRows.length; i += BATCH) {
const batch = validRows.slice(i, i + BATCH);
const poles = batch.map((row) => ({
id: row.Path.replace(/\.jpg$/i, ''),
lat: parseFloat(row.GPS_lat),
lng: parseFloat(row.GPS_lon),
gu: row.Area || '',
dong: row.Juso || '',
timestamp: row.Date || '',
level: row.Level || '',
photo_path: basename(row.Path),
}));
const { data, error } = await supabase.from('poles').upsert(poles, { onConflict: 'id' });
if (error) {
console.error(` ⚠ 배치 ${i}-${i + batch.length} 실패:`, error.message);
failed += batch.length;
} else {
inserted += (data?.length || batch.length);
}
if ((i + BATCH) % 200 === 0 || i + BATCH >= validRows.length) {
console.log(` 진행: ${Math.min(i + BATCH, validRows.length)}/${validRows.length}`);
}
}
console.log(` 완료 - 삽입/업데이트: ${inserted}행, 실패: ${failed}행`);
}
// ── main ──
async function main() {
console.log('=== saftyovhcable.kr 데이터 마이그레이션 ===\n');
const { header, dataLines } = await fetchPageCSV();
const { valid } = parseCSVLines(header, dataLines);
await downloadThumbs(valid);
await insertToDB(valid);
console.log('\n=== 마이그레이션 완료 ===');
}
main().catch((e) => {
console.error('치명적 에러:', e);
process.exit(1);
});
+65
View File
@@ -0,0 +1,65 @@
const VWORLD_ENDPOINT = 'https://api.vworld.kr/req/data';
function credentials() {
return {
key: process.env.VWORLD_API_KEY || process.env.VITE_VWORLD_API_KEY || '',
domain: process.env.VWORLD_DOMAIN || 'localhost',
};
}
async function fetchVworldFeatures(data, attrFilter) {
const { key, domain } = credentials();
if (!key) throw new Error('VWORLD_API_KEY 환경변수가 설정되지 않았습니다.');
const size = 1000;
const base = new URLSearchParams({
service: 'data',
request: 'GetFeature',
data,
format: 'json',
geometry: 'false',
size: String(size),
key,
});
if (attrFilter) base.set('attrFilter', attrFilter);
base.set('domain', domain);
const features = [];
let page = 1;
let total = Infinity;
while (features.length < total && page <= 20) {
const params = new URLSearchParams(base);
params.set('page', String(page));
const res = await fetch(`${VWORLD_ENDPOINT}?${params}`);
if (!res.ok) throw new Error(`VWorld 요청 실패 (HTTP ${res.status})`);
const json = await res.json();
const response = json.response;
if (response?.status !== 'OK') {
throw new Error(`VWorld 조회 실패: ${JSON.stringify(response ?? json)}`);
}
const result = response.result?.featureCollection?.features ?? [];
features.push(...result);
total = Number(response.record?.total ?? 0) || total;
if (result.length === 0) break;
page += 1;
}
return features;
}
export async function fetchGus() {
const features = await fetchVworldFeatures('LT_C_ADSIGG_INFO', 'full_nm:like:서울');
return features.map((f) => ({
code: f.properties.sig_cd,
name: f.properties.sig_kor_nm,
}));
}
export async function fetchDongs(guCode) {
const features = await fetchVworldFeatures('LT_C_ADEMD_INFO', `emd_cd:like:${guCode}`);
return features.map((f) => f.properties.emd_kor_nm).filter(Boolean);
}
+413
View File
@@ -0,0 +1,413 @@
.app {
width: 100vw;
height: 100vh;
position: relative;
}
.map-container {
width: 100%;
height: 100%;
background-color: #eee;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.4);
z-index: 20;
color: white;
font-size: 18px;
font-weight: bold;
}
.error-banner {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
background-color: #ef4444;
color: white;
padding: 12px 24px;
border-radius: 8px;
z-index: 30;
font-weight: bold;
}
.capture-bar {
position: absolute;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
gap: 12px;
align-items: center;
}
.camera-input {
display: none;
}
.capture-button {
padding: 15px 30px;
background-color: #10b981;
color: white;
border-radius: 30px;
font-weight: bold;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
cursor: pointer;
display: inline-block;
}
.locate-btn {
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
background-color: white;
font-size: 20px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.upload-progress {
position: absolute;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.75);
color: white;
padding: 10px 20px;
border-radius: 20px;
z-index: 15;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
}
.list-toggle {
position: absolute;
top: 10px;
right: 10px;
background-color: white;
padding: 8px 16px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 10;
font-size: 14px;
font-weight: bold;
border: none;
cursor: pointer;
}
.gps-info {
position: absolute;
top: 50px;
right: 10px;
color: white;
padding: 8px 12px;
border-radius: 8px;
z-index: 10;
font-size: 12px;
max-width: 200px;
line-height: 1.4;
}
.gps-info--fallback {
background-color: #f59e0b;
}
.gps-info--normal {
background-color: #10b981;
}
.gps-info__title {
font-weight: bold;
}
.backdrop {
position: absolute;
inset: 0;
background-color: rgba(0, 0, 0, 0.3);
z-index: 40;
}
.pole-list {
position: absolute;
top: 50px;
right: 10px;
width: min(320px, calc(100vw - 20px));
background-color: white;
border-radius: 12px;
z-index: 50;
transition: max-height 0.3s ease, opacity 0.2s ease;
overflow: hidden;
display: flex;
flex-direction: column;
}
.pole-list--open {
max-height: 75vh;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
opacity: 1;
pointer-events: auto;
}
.pole-list--closed {
max-height: 0;
box-shadow: none;
opacity: 0;
pointer-events: none;
}
.pole-list__header {
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.pole-list__title {
font-weight: bold;
font-size: 14px;
}
.pole-list__close {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
color: #6b7280;
}
.pole-list__header-actions {
display: flex;
align-items: center;
gap: 6px;
}
.pole-list__upload {
padding: 4px 10px;
border-radius: 6px;
border: 1px solid #d1d5db;
background: white;
font-size: 12px;
font-weight: 600;
cursor: pointer;
color: #374151;
white-space: nowrap;
}
.pole-list__upload:hover {
background-color: #f3f4f6;
}
.pole-list__scroll {
overflow-y: auto;
flex: 1;
}
.pole-list__filters {
padding: 8px 12px;
border-bottom: 1px solid #e5e7eb;
display: flex;
gap: 6px;
flex-shrink: 0;
}
.pole-filter-btn {
padding: 4px 12px;
border-radius: 14px;
border: 1px solid #d1d5db;
background: white;
font-size: 12px;
font-weight: 600;
cursor: pointer;
color: #374151;
transition: background-color 0.15s, color 0.15s;
}
.pole-filter-btn--active {
border-color: transparent;
}
.pole-list__area-filter {
padding: 6px 12px;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
display: flex;
gap: 6px;
}
.pole-area-select {
flex: 1;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 13px;
color: #374151;
background: white;
cursor: pointer;
outline: none;
min-width: 0;
}
.pole-area-select:focus {
border-color: #6366f1;
}
.pole-item__level {
display: inline-block;
padding: 1px 6px;
border-radius: 4px;
color: white;
font-size: 10px;
font-weight: 700;
margin-right: 6px;
vertical-align: middle;
}
.pole-item {
display: flex;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid #f3f4f6;
gap: 10px;
cursor: pointer;
}
.pole-item__thumb {
width: 48px;
height: 48px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
}
.pole-item__info {
flex: 1;
min-width: 0;
}
.pole-item__coords {
font-size: 12px;
color: #374151;
font-weight: 500;
}
.pole-item__time {
font-size: 11px;
color: #9ca3af;
margin-top: 2px;
}
.pole-item__delete {
background: none;
border: none;
font-size: 16px;
cursor: pointer;
padding: 6px;
color: #ef4444;
flex-shrink: 0;
}
.map-popup {
position: absolute;
background: white;
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
padding: 8px;
min-width: 180px;
max-width: 240px;
pointer-events: auto;
}
.map-popup:empty {
display: none;
}
.map-popup__close {
position: absolute;
top: 4px;
right: 4px;
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.45);
color: white;
font-size: 12px;
line-height: 22px;
text-align: center;
cursor: pointer;
padding: 0;
z-index: 1;
}
.map-popup__close:hover {
background: rgba(0, 0, 0, 0.7);
}
.map-popup__img {
width: 100%;
border-radius: 6px;
display: block;
object-fit: cover;
max-height: 160px;
}
.map-popup__info {
margin-top: 6px;
font-size: 11px;
color: #6b7280;
line-height: 1.4;
}
.map-popup__addr {
margin-left: 4px;
}
@media (min-width: 900px) {
.list-toggle,
.backdrop,
.pole-list__close {
display: none;
}
.gps-info {
top: 10px;
left: 10px;
right: auto;
}
.pole-list,
.pole-list--open,
.pole-list--closed {
top: 0;
right: 0;
width: 360px;
max-height: 100vh;
border-radius: 0;
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.15);
opacity: 1;
pointer-events: auto;
}
.capture-button {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
}
.pole-item:hover {
background-color: #f9fafb;
}
}
+734
View File
@@ -0,0 +1,734 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Map from 'ol/Map';
import View from 'ol/View';
import Overlay from 'ol/Overlay';
import TileLayer from 'ol/layer/Tile';
import XYZ from 'ol/source/XYZ';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import Feature from 'ol/Feature';
import Point from 'ol/geom/Point';
import { Style, Circle as CircleStyle, Fill, Stroke, Text } from 'ol/style';
import { fromLonLat } from 'ol/proj';
import imageCompression from 'browser-image-compression';
import exifr from 'exifr';
import './App.css';
const VWORLD_API_KEY = '2C00322C-5037-37EB-A547-7001C13840E9';
const LEVEL_COLORS = { A: '#22c55e', B: '#f97316', Pole: '#a855f7' };
const LEVEL_LABELS = { A: 'A', B: 'B', Pole: 'P' };
function createTileSource(layer) {
return new XYZ({
url: `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/${layer}/{z}/{y}/{x}.png`,
crossOrigin: null,
maxZoom: 19,
minZoom: 6,
});
}
function App() {
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
const markerLayerRef = useRef(null);
const lastPositionRef = useRef(null);
const watchIdRef = useRef(null);
const fallbackUsedRef = useRef(false);
const currentLocationRef = useRef(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [poles, setPoles] = useState([]);
const [processing, setProcessing] = useState(false);
const [gpsInfo, setGpsInfo] = useState(null);
const [listOpen, setListOpen] = useState(false);
const [uploadProgress, setUploadProgress] = useState(null);
const folderInputRef = useRef(null);
const [levelFilter, setLevelFilter] = useState('all');
const [guFilter, setGuFilter] = useState('용산구');
const [dongFilter, setDongFilter] = useState('all');
const polesRef = useRef([]);
const [allDongs, setAllDongs] = useState([]);
const popupRef = useRef(null);
const popupOverlayRef = useRef(null);
const [popupPole, setPopupPole] = useState(null);
const guList = useMemo(() => {
const set = new Set(allDongs.map((d) => d.gu_name));
return [...set].sort();
}, [allDongs]);
const dongList = useMemo(() => {
if (guFilter === 'all') return allDongs;
return allDongs.filter((d) => d.gu_name === guFilter);
}, [allDongs, guFilter]);
const filteredPoles = useMemo(() => {
return poles.filter(
(p) => (levelFilter === 'all' || p.level === levelFilter) &&
(guFilter === 'all' || p.dong.includes(guFilter)) &&
(dongFilter === 'all' || p.dong.includes(dongFilter))
);
}, [poles, levelFilter, guFilter, dongFilter]);
useEffect(() => {
if (!gpsInfo) return;
const timer = setTimeout(() => setGpsInfo(null), 3000);
return () => clearTimeout(timer);
}, [gpsInfo]);
const syncMarkers = useCallback(() => {
if (!markerLayerRef.current) return;
const source = markerLayerRef.current.getSource();
source.getFeatures().forEach((f) => {
if (f.get('poleId') !== 'current-location') source.removeFeature(f);
});
const idSet = new Set();
polesRef.current.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;
idSet.add(pole.id);
const feature = new Feature({
geometry: new Point(fromLonLat([pole.lng, pole.lat])),
poleId: pole.id,
});
feature.setStyle(
new Style({
image: new CircleStyle({
radius: 8,
fill: new Fill({ color: LEVEL_COLORS[pole.level] || '#ef4444' }),
stroke: new Stroke({ color: 'white', width: 2 }),
}),
text: new Text({
text: LEVEL_LABELS[pole.level] || '전신주',
offsetY: -20,
font: 'bold 12px sans-serif',
fill: new Fill({ color: '#1e293b' }),
stroke: new Stroke({ color: 'white', width: 3 }),
}),
}),
);
source.addFeature(feature);
});
}, [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);
});
} catch (err) {
console.error('전신주 삭제 실패:', err);
alert('전신주 삭제에 실패했습니다.');
}
}, []);
const flyTo = useCallback((lng, lat) => {
if (!mapRef.current) return;
mapRef.current.getView().animate({
center: fromLonLat([lng, lat]),
zoom: 18,
duration: 500,
});
}, []);
const flyToCurrentLocation = useCallback(() => {
const pos = lastPositionRef.current;
if (!pos || !mapRef.current) return;
mapRef.current.getView().animate({
center: fromLonLat([pos.lng, pos.lat]),
zoom: 17,
duration: 500,
});
}, []);
useEffect(() => {
if (mapRef.current) return;
let aborted = false;
const initMap = (lng, lat) => {
if (aborted || mapRef.current) return;
try {
const markerSource = new VectorSource();
const markerLayer = new VectorLayer({ source: markerSource });
markerLayerRef.current = markerLayer;
const map = new Map({
target: mapContainerRef.current,
layers: [
new TileLayer({ source: createTileSource('Base') }),
new TileLayer({ source: createTileSource('Hybrid') }),
markerLayer,
],
view: new View({
center: fromLonLat([lng, lat]),
zoom: 17,
maxZoom: 19,
minZoom: 6,
}),
});
mapRef.current = map;
const popupOverlay = new Overlay({
element: popupRef.current,
autoPan: { animation: { duration: 200 } },
});
map.addOverlay(popupOverlay);
popupOverlayRef.current = popupOverlay;
map.on('singleclick', (evt) => {
const feature = map.forEachFeatureAtPixel(evt.pixel, (f) => f);
if (!feature || feature.get('poleId') === 'current-location') {
setPopupPole(null);
popupOverlay.setPosition(undefined);
return;
}
const poleId = feature.get('poleId');
const pole = polesRef.current.find((p) => p.id === poleId);
if (!pole) return;
setPopupPole(pole);
popupOverlay.setPosition(evt.coordinate);
});
addMarker(lng, lat, 'current-location');
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(); }),
])
.then(([poleResult, dongResult]) => {
if (aborted) return;
if (poleResult.status === 'fulfilled') {
polesRef.current = poleResult.value;
setPoles(poleResult.value);
}
if (dongResult.status === 'fulfilled') {
setAllDongs(dongResult.value);
}
})
.catch((err) => {
console.error('전신주 목록 불러오기 실패:', err);
})
.finally(() => {
if (!aborted) setLoading(false);
});
} catch (e) {
console.error('지도 초기화 실패:', e);
setError('지도를 불러오지 못했습니다.');
setLoading(false);
}
};
const handleSuccess = (position) => {
if (aborted) return;
const { latitude: lat, longitude: lng } = position.coords;
lastPositionRef.current = { lat, lng };
initMap(lng, lat);
};
const handleError = (err) => {
if (aborted) return;
console.error('getCurrentPosition 실패:', err?.code, err?.message);
const fallback = { lat: 37.5665, lng: 126.978 };
lastPositionRef.current = fallback;
fallbackUsedRef.current = true;
initMap(fallback.lng, fallback.lat);
};
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(handleSuccess, handleError, {
enableHighAccuracy: true,
});
watchIdRef.current = navigator.geolocation.watchPosition(
(position) => {
const { latitude: lat, longitude: lng } = position.coords;
lastPositionRef.current = { lat, lng };
if (currentLocationRef.current) {
currentLocationRef.current.getGeometry().setCoordinates(fromLonLat([lng, lat]));
}
if (fallbackUsedRef.current) {
fallbackUsedRef.current = false;
mapRef.current?.getView().animate({
center: fromLonLat([lng, lat]),
zoom: 17,
duration: 500,
});
}
},
(err) => {
console.error('watchPosition 실패:', err.code, err.message);
},
{ enableHighAccuracy: true, maximumAge: 10000 },
);
} else {
handleError();
}
return () => {
aborted = true;
if (watchIdRef.current != null) {
navigator.geolocation.clearWatch(watchIdRef.current);
watchIdRef.current = null;
}
if (mapRef.current) {
mapRef.current.setTarget(undefined);
mapRef.current = null;
}
markerLayerRef.current = null;
currentLocationRef.current = null;
fallbackUsedRef.current = false;
};
}, [addMarker]);
const handleCapture = async (e) => {
const file = e.target.files?.[0];
if (!file || processing) return;
setProcessing(true);
setGpsInfo(null);
try {
let lat;
let lng;
let source = '';
// 1차: 사진 EXIF에서 GPS 추출 (ArrayBuffer로 읽어 Android 호환성 확보)
try {
const buffer = await file.arrayBuffer();
console.log('파일 타입:', file.type, '크기:', file.size, 'buffer 크기:', buffer.byteLength);
const exifData = await exifr.parse(buffer);
console.log('EXIF 전체 데이터:', exifData);
if (exifData && exifData.latitude && exifData.longitude) {
lat = exifData.latitude;
lng = exifData.longitude;
source = 'EXIF';
lastPositionRef.current = { lat, lng };
} else {
console.log('EXIF에 GPS 데이터 없음');
}
} catch (err) {
console.error('EXIF 읽기 실패:', err);
}
// 2차: 브라우저 GPS (EXIF에 GPS가 없는 경우)
if (lat === undefined && navigator.geolocation) {
try {
console.log('브라우저 GPS 요청 중...');
const pos = await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 30000,
});
});
lat = pos.coords.latitude;
lng = pos.coords.longitude;
source = '브라우저 GPS';
lastPositionRef.current = { lat, lng };
} catch (err) {
console.error('브라우저 GPS 실패:', err);
}
}
// 3차: 마지막 알려진 위치 (watchPosition 또는 초기 위치)
if (lat === undefined) {
const last = lastPositionRef.current;
if (last) {
lat = last.lat;
lng = last.lng;
source = watchIdRef.current != null ? '실시간 GPS' : '폴백 (초기 위치)';
} else {
throw new Error('No GPS available');
}
}
setGpsInfo({ source, lat, lng });
const compressed = await imageCompression(file, {
maxSizeMB: 0.5,
maxWidthOrHeight: 800,
useWebWorker: true,
});
const id = `pole-${Date.now()}`;
const formData = new FormData();
formData.append('photo', compressed, `${id}.jpg`);
formData.append('id', id);
formData.append('lat', lat);
formData.append('lng', lng);
formData.append('timestamp', new Date().toISOString());
const res = await fetch('/api/poles', { method: 'POST', body: formData });
if (!res.ok) throw new Error(`등록 실패 (HTTP ${res.status})`);
const pole = await res.json();
setPoles((prev) => {
polesRef.current = [...prev, pole];
return [...prev, pole];
});
} catch (err) {
console.error('전신주 등록 실패:', err);
alert('전신주 등록에 실패했습니다. 위치 정보를 가져올 수 없습니다.');
} finally {
setProcessing(false);
e.target.value = '';
}
};
const handleFolderUpload = async (e) => {
const files = Array.from(e.target.files || []);
const imageFiles = files.filter((f) =>
f.type.startsWith('image/') || /\.(jpe?g|png|heic|webp|bmp|gif)$/i.test(f.name)
);
if (imageFiles.length === 0) {
alert('이미지 파일이 없습니다.');
e.target.value = '';
return;
}
setProcessing(true);
setUploadProgress({ current: 0, total: imageFiles.length });
let registered = 0;
let skipped = 0;
for (let i = 0; i < imageFiles.length; i++) {
const file = imageFiles[i];
setUploadProgress({ current: i + 1, total: imageFiles.length });
try {
let lat;
let lng;
try {
const buffer = await file.arrayBuffer();
const exifData = await exifr.parse(buffer);
if (exifData && exifData.latitude && exifData.longitude) {
lat = exifData.latitude;
lng = exifData.longitude;
lastPositionRef.current = { lat, lng };
}
} catch (err) {
console.error('EXIF 읽기 실패:', file.name, err);
}
if (lat === undefined) {
skipped++;
continue;
}
const compressed = await imageCompression(file, {
maxSizeMB: 0.5,
maxWidthOrHeight: 800,
useWebWorker: true,
});
const id = `pole-${Date.now()}-${i}`;
const formData = new FormData();
formData.append('photo', compressed, `${id}.jpg`);
formData.append('id', id);
formData.append('lat', lat);
formData.append('lng', lng);
formData.append('timestamp', new Date().toISOString());
const res = await fetch('/api/poles', { method: 'POST', body: formData });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const pole = await res.json();
setPoles((prev) => {
polesRef.current = [...prev, pole];
return [...prev, pole];
});
registered++;
} catch (err) {
console.error('전신주 등록 실패:', file.name, err);
skipped++;
}
}
setUploadProgress(null);
setProcessing(false);
alert(`등록 완료: ${registered}장${skipped > 0 ? ` / 건너뜀: ${skipped}장 (GPS 없음)` : ''}`);
e.target.value = '';
};
return (
<main className="app">
<div ref={mapContainerRef} className="map-container" />
<div ref={popupRef} className="map-popup">
{popupPole && (
<>
<button
type="button"
className="map-popup__close"
onClick={() => {
setPopupPole(null);
popupOverlayRef.current?.setPosition(undefined);
}}
>
✕
</button>
<img src={popupPole.photoUrl} alt="전신주" className="map-popup__img" />
<div className="map-popup__info">
<span
className="pole-item__level"
style={{ backgroundColor: LEVEL_COLORS[popupPole.level] || '#94a3b8' }}
>
{LEVEL_LABELS[popupPole.level] || popupPole.level || '-'}
</span>
<span className="map-popup__addr">{popupPole.dong}</span>
</div>
</>
)}
</div>
{(loading || processing) && !uploadProgress && (
<div className="overlay">
{loading ? '지도 불러오는 중...' : '전신주 등록 중...'}
</div>
)}
{error && <div className="error-banner">{error}</div>}
<div className="capture-bar">
<input
type="file"
accept="image/*"
capture="environment"
id="camera-input"
className="camera-input"
onChange={handleCapture}
/>
<input
type="file"
accept="image/*"
webkitdirectory=""
id="folder-input"
className="camera-input"
ref={folderInputRef}
onChange={handleFolderUpload}
/>
<label htmlFor="camera-input" className="capture-button">
📷 등록
</label>
<button
type="button"
className="locate-btn"
onClick={flyToCurrentLocation}
>
📍
</button>
</div>
{uploadProgress && (
<div className="upload-progress">
{uploadProgress.current}/{uploadProgress.total} 업로드 중...
</div>
)}
{poles.length > 0 && (
<button
type="button"
onClick={() => setListOpen((o) => !o)}
className="list-toggle"
>
📋 {filteredPoles.length}기
</button>
)}
{gpsInfo && (
<div
className={
gpsInfo.source === '폴백 (초기 위치)'
? 'gps-info gps-info--fallback'
: 'gps-info gps-info--normal'
}
>
<div className="gps-info__title">📍 {gpsInfo.source}</div>
<div>{gpsInfo.lat.toFixed(5)}, {gpsInfo.lng.toFixed(5)}</div>
</div>
)}
{listOpen && (
<div
className="backdrop"
onClick={() => setListOpen(false)}
onKeyDown={() => setListOpen(false)}
role="button"
tabIndex={0}
/>
)}
<div
className={listOpen ? 'pole-list pole-list--open' : 'pole-list pole-list--closed'}
>
<div className="pole-list__header">
<span className="pole-list__title">
전신주 목록 ({filteredPoles.length}기)
</span>
<div className="pole-list__header-actions">
<button
type="button"
className="pole-list__upload"
onClick={() => document.getElementById('folder-input').click()}
>
📁 폴더 업로드
</button>
<button
type="button"
onClick={() => setListOpen(false)}
className="pole-list__close"
>
✕
</button>
</div>
</div>
<div className="pole-list__filters">
{[
{ value: 'all', label: '전체', color: '#94a3b8' },
{ value: 'A', label: 'A', color: LEVEL_COLORS.A },
{ value: 'B', label: 'B', color: LEVEL_COLORS.B },
{ value: 'Pole', label: 'Pole', color: LEVEL_COLORS.Pole },
].map((f) => (
<button
key={f.value}
type="button"
className={`pole-filter-btn ${levelFilter === f.value ? 'pole-filter-btn--active' : ''}`}
style={levelFilter === f.value ? { backgroundColor: f.color, color: 'white' } : {}}
onClick={() => setLevelFilter(f.value)}
>
{f.label}
</button>
))}
</div>
<div className="pole-list__area-filter">
<select
className="pole-area-select"
value={guFilter}
onChange={(e) => {
setGuFilter(e.target.value);
setDongFilter('all');
}}
>
<option value="all">전체 구</option>
{guList.map((gu) => {
const count = poles.filter((p) => p.dong.includes(gu)).length;
return (
<option key={gu} value={gu}>{gu} ({count}기)</option>
);
})}
</select>
<select
className="pole-area-select"
value={dongFilter}
onChange={(e) => setDongFilter(e.target.value)}
>
<option value="all">전체 동</option>
{dongList.map((d) => {
const count = poles.filter((p) => p.dong.includes(d.dong)).length;
return (
<option key={d.dong} value={d.dong}>{d.dong} ({count}기)</option>
);
})}
</select>
</div>
<div className="pole-list__scroll">
{[...filteredPoles].reverse().map((pole) => (
<div
key={pole.id}
className="pole-item"
onClick={() => {
flyTo(pole.lng, pole.lat);
setListOpen(false);
}}
onKeyDown={() => {
flyTo(pole.lng, pole.lat);
setListOpen(false);
}}
role="button"
tabIndex={0}
>
<img src={pole.photoUrl} alt="전신주" className="pole-item__thumb" />
<div className="pole-item__info">
<div className="pole-item__coords">
<span
className="pole-item__level"
style={{ backgroundColor: LEVEL_COLORS[pole.level] || '#94a3b8' }}
>
{LEVEL_LABELS[pole.level] || pole.level || '-'}
</span>
{pole.lat.toFixed(5)}, {pole.lng.toFixed(5)}
</div>
<div className="pole-item__time">
{new Date(pole.timestamp).toLocaleString('ko-KR')}
</div>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDelete(pole.id);
}}
className="pole-item__delete"
>
🗑
</button>
</div>
))}
</div>
</div>
</main>
);
}
export default App;
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+16
View File
@@ -0,0 +1,16 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, sans-serif;
overflow: hidden;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
#root {
width: 100vw;
height: 100vh;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);
+29
View File
@@ -0,0 +1,29 @@
-- Pole Mapper Supabase 스키마
-- Supabase 대시보드 > SQL Editor에서 실행
create table if not exists gus (
code text primary key,
name text not null unique,
updated_at timestamptz not null
);
create table if not exists dongs (
gu_name text not null,
dong text not null,
updated_at timestamptz not null,
primary key (gu_name, dong)
);
create index if not exists idx_dongs_gu on dongs(gu_name);
create table if not exists poles (
id text primary key,
lat double precision not null,
lng double precision not null,
gu text not null default '',
dong text not null default '',
timestamp text not null,
level text not null default '',
photo_path text
);
create index if not exists idx_poles_gu_dong on poles(gu, dong);
create index if not exists idx_poles_timestamp on poles(timestamp);
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import fs from 'fs'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: true,
https: {
key: fs.readFileSync(path.join(process.env.USERPROFILE, '.vite-key.pem')),
cert: fs.readFileSync(path.join(process.env.USERPROFILE, '.vite-cert.pem')),
},
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:4000',
changeOrigin: true,
},
},
},
})