mirror of
https://github.com/15000rpm/Pole-Mapper.git
synced 2026-10-09 10:45:10 +09:00
Compare commits
5
Commits
a5a1b2a2a2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc4d112324 | ||
|
|
b32c2fbc0a | ||
|
|
70c9edb7c5 | ||
|
|
262c74fbb6 | ||
|
|
13905babf3 |
+198
@@ -0,0 +1,198 @@
|
|||||||
|
# 로그인 기능 추가 구현 계획
|
||||||
|
|
||||||
|
## 1. 사전 준비 (Supabase 대시보드)
|
||||||
|
|
||||||
|
- Authentication → Providers → **Email 활성화**
|
||||||
|
- (선택) 휴대전화 인증 추가 (Twilio 연동 필요)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. DB 스키마 변경 (`supabase-schema.sql`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- profiles 테이블
|
||||||
|
create table if not exists profiles (
|
||||||
|
id uuid primary key references auth.users(id) on delete cascade,
|
||||||
|
role text not null default 'user' check (role in ('admin', 'user')),
|
||||||
|
phone text,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- poles RLS
|
||||||
|
alter table poles enable row level security;
|
||||||
|
|
||||||
|
create policy "인증된 사용자 poles 조회"
|
||||||
|
on poles for select
|
||||||
|
to authenticated
|
||||||
|
using (true);
|
||||||
|
|
||||||
|
create policy "인증된 사용자 poles 추가"
|
||||||
|
on poles for insert
|
||||||
|
to authenticated
|
||||||
|
with check (true);
|
||||||
|
|
||||||
|
create policy "본인 poles 삭제 또는 admin"
|
||||||
|
on poles for delete
|
||||||
|
to authenticated
|
||||||
|
using (
|
||||||
|
auth.uid() = (select user_id from profiles where id = poles.id)
|
||||||
|
or (select role from profiles where id = auth.uid()) = 'admin'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- gus/dongs RLS (인증된 사용자 SELECT만 허용)
|
||||||
|
alter table gus enable row level security;
|
||||||
|
alter table dongs enable row level security;
|
||||||
|
|
||||||
|
create policy "인증된 사용자 gus 조회"
|
||||||
|
on gus for select
|
||||||
|
to authenticated
|
||||||
|
using (true);
|
||||||
|
|
||||||
|
create policy "인증된 사용자 dongs 조회"
|
||||||
|
on dongs for select
|
||||||
|
to authenticated
|
||||||
|
using (true);
|
||||||
|
|
||||||
|
-- 신규 회원가입 시 profiles 자동 생성 트리거
|
||||||
|
create or replace function public.handle_new_user()
|
||||||
|
returns trigger as $$
|
||||||
|
begin
|
||||||
|
insert into public.profiles (id, role)
|
||||||
|
values (new.id, 'user');
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$ language plpgsql security definer;
|
||||||
|
|
||||||
|
create or replace trigger on_auth_user_created
|
||||||
|
after insert on auth.users
|
||||||
|
for each row execute function public.handle_new_user();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 환경 변수 (`.env`, `server/.env`, `.env.example`)
|
||||||
|
|
||||||
|
| 변수 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| `VITE_SUPABASE_URL` | 프론트엔드 Supabase URL |
|
||||||
|
| `VITE_SUPABASE_ANON_KEY` | 프론트엔드 Supabase anon key |
|
||||||
|
| `SUPABASE_ANON_KEY` | 서버 JWT 검증용 anon key |
|
||||||
|
|
||||||
|
> anon key는 RLS를 따르므로 클라이언트 노출이 안전합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 서버 측 변경
|
||||||
|
|
||||||
|
### `server/auth.js` (신규)
|
||||||
|
|
||||||
|
- anon key 기반 Supabase 클라이언트 생성
|
||||||
|
- `requireAuth` 미들웨어 — `Authorization: Bearer <token>` 토큰 검증 → `req.user` 주입
|
||||||
|
- `requireRole(role)` 미들웨어 — 역할 확인
|
||||||
|
|
||||||
|
### `server/db.js` 변경
|
||||||
|
|
||||||
|
- `createProfile(userId, role, phone)`
|
||||||
|
- `getProfile(userId)`
|
||||||
|
- `updateProfileRole(userId, role)`
|
||||||
|
- `getAllProfiles()`
|
||||||
|
|
||||||
|
### `server/index.js` 변경
|
||||||
|
|
||||||
|
**신규 API 라우트:**
|
||||||
|
| 메서드 | 경로 | 설명 | 권한 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| POST | `/api/auth/signup` | 회원가입 (email, password, phone?) | 공개 |
|
||||||
|
| POST | `/api/auth/login` | 로그인 → JWT 토큰 반환 | 공개 |
|
||||||
|
| GET | `/api/auth/me` | 현재 사용자 정보 | 인증 |
|
||||||
|
| GET | `/api/auth/users` | 사용자 목록 | admin |
|
||||||
|
| PATCH | `/api/auth/users/:id/role` | 역할 변경 | admin |
|
||||||
|
|
||||||
|
**기존 라우트 미들웨어 적용:**
|
||||||
|
| 라우트 | 권한 |
|
||||||
|
|--------|------|
|
||||||
|
| GET/POST `/api/poles` | `requireAuth` |
|
||||||
|
| DELETE `/api/poles/:id` | `requireAuth` + 본인 데이터 또는 admin |
|
||||||
|
| GET `/api/gus` | `requireAuth` |
|
||||||
|
| GET `/api/dongs` | `requireAuth` |
|
||||||
|
| GET `/api/reverse-geocode` | `requireAuth` |
|
||||||
|
| GET `/api/all-dongs` | `requireAuth` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 프론트엔드 변경
|
||||||
|
|
||||||
|
### `src/lib/supabase.js` (신규)
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
export const supabase = createClient(
|
||||||
|
import.meta.env.VITE_SUPABASE_URL,
|
||||||
|
import.meta.env.VITE_SUPABASE_ANON_KEY
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `src/App.jsx` 변경
|
||||||
|
|
||||||
|
**인증 상태 관리:**
|
||||||
|
- `user` state — Supabase auth 사용자 객체
|
||||||
|
- `profile` state — profiles 테이블에서 가져온 역할 정보
|
||||||
|
- `useEffect` → `supabase.auth.onAuthStateChange()` 리스닝
|
||||||
|
- 컴포넌트 마운트 시 `supabase.auth.getSession()`으로 기존 세션 복원
|
||||||
|
|
||||||
|
**조건부 렌더링:**
|
||||||
|
- `user === null` → LoginForm / RegisterForm 표시
|
||||||
|
- `user !== null` → 기존 지도 앱 + 상단 UserMenu
|
||||||
|
|
||||||
|
**LoginForm:**
|
||||||
|
- 이메일 + 비밀번호 입력
|
||||||
|
- `supabase.auth.signInWithPassword({ email, password })`
|
||||||
|
|
||||||
|
**RegisterForm:**
|
||||||
|
- 이메일 + 비밀번호 + (선택) 전화번호 입력
|
||||||
|
- `supabase.auth.signUp({ email, password, options: { data: { phone } } })`
|
||||||
|
|
||||||
|
**UserMenu (상단):**
|
||||||
|
- 사용자 이메일/역할 표시
|
||||||
|
- 로그아웃 버튼 (`supabase.auth.signOut()`)
|
||||||
|
- admin → 사용자관리 링크
|
||||||
|
|
||||||
|
**UserManagement (admin 전용 패널):**
|
||||||
|
- 사용자 목록 조회 (`GET /api/auth/users`)
|
||||||
|
- 역할 드롭다운으로 변경 (`PATCH /api/auth/users/:id/role`)
|
||||||
|
|
||||||
|
**API 호출 수정 (모든 fetch):**
|
||||||
|
```js
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
fetch('/api/poles', {
|
||||||
|
headers: { Authorization: `Bearer ${session?.access_token}` }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 파일 변경 요약
|
||||||
|
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `supabase-schema.sql` | profiles 테이블 + RLS + 트리거 추가 |
|
||||||
|
| `.env` / `server/.env` / `.env.example` | 환경 변수 3개 추가 |
|
||||||
|
| `server/auth.js` | **신규** — anon 클라이언트 + JWT 미들웨어 |
|
||||||
|
| `server/db.js` | profiles CRUD 함수 추가 |
|
||||||
|
| `server/index.js` | auth 라우트 + 기존 라우트에 미들웨어 적용 |
|
||||||
|
| `src/lib/supabase.js` | **신규** — 클라이언트 Supabase 인스턴스 |
|
||||||
|
| `src/App.jsx` | 인증 UI + 상태 관리 + API 토큰 헤더 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 구현 순서
|
||||||
|
|
||||||
|
1. **Supabase 대시보드**에서 Email auth 활성화
|
||||||
|
2. **SQL 스키마** 업데이트 (profiles + RLS + 트리거)
|
||||||
|
3. **환경 변수** 추가
|
||||||
|
4. **`server/auth.js`** 생성
|
||||||
|
5. **`server/db.js`**에 profiles 함수 추가
|
||||||
|
6. **`server/index.js`**에 auth 라우트 + 미들웨어 적용
|
||||||
|
7. **`src/lib/supabase.js`** 생성
|
||||||
|
8. **`src/App.jsx`**에 인증 UI + 토큰 헤더 추가
|
||||||
|
9. **테스트** — 회원가입, 로그인, API 접근, 권한 확인
|
||||||
Generated
+9
-9
@@ -13,7 +13,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.2.1",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"ol": "^10.9.0",
|
"ol": "^10.9.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
"concurrently": "^9.2.0",
|
"concurrently": "^9.2.4",
|
||||||
"oxlint": "^1.71.0",
|
"oxlint": "^1.71.0",
|
||||||
"vite": "^8.1.1"
|
"vite": "^8.1.1"
|
||||||
}
|
}
|
||||||
@@ -2082,9 +2082,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.16",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2301,9 +2301,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.20",
|
"version": "8.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||||
"integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==",
|
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2321,7 +2321,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.16",
|
"nanoid": "^3.3.17",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.2.1",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"ol": "^10.9.0",
|
"ol": "^10.9.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
"concurrently": "^9.2.0",
|
"concurrently": "^9.2.4",
|
||||||
"oxlint": "^1.71.0",
|
"oxlint": "^1.71.0",
|
||||||
"vite": "^8.1.1"
|
"vite": "^8.1.1"
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-5
@@ -71,6 +71,19 @@ app.get('/api/dongs', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/reverse-geocode', async (req, res) => {
|
||||||
|
const numLat = Number(req.query.lat);
|
||||||
|
const numLng = Number(req.query.lng);
|
||||||
|
if (!Number.isFinite(numLat) || !Number.isFinite(numLng)) {
|
||||||
|
return res.status(400).json({ error: 'lat, lng 파라미터가 필요합니다.' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.json(await reverseGeocode(numLat, numLng));
|
||||||
|
} catch (e) {
|
||||||
|
res.status(502).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/poles', async (req, res) => {
|
app.get('/api/poles', async (req, res) => {
|
||||||
const { gu = '', dong = '', start = '', end = '' } = req.query;
|
const { gu = '', dong = '', start = '', end = '' } = req.query;
|
||||||
try {
|
try {
|
||||||
@@ -128,19 +141,33 @@ app.post('/api/poles', upload.single('photo'), async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let allDongsRefreshPromise = null;
|
||||||
|
|
||||||
app.get('/api/all-dongs', async (_req, res) => {
|
app.get('/api/all-dongs', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
if (allDongsRefreshPromise) {
|
||||||
|
res.json(await allDongsRefreshPromise);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!(await db.isGusFresh(CACHE_TTL_MS))) {
|
if (!(await db.isGusFresh(CACHE_TTL_MS))) {
|
||||||
await db.replaceGus(await fetchGus());
|
await db.replaceGus(await fetchGus());
|
||||||
}
|
}
|
||||||
const gus = await db.getGus();
|
const gus = await db.getGus();
|
||||||
|
const stale = [];
|
||||||
for (const gu of gus) {
|
for (const gu of gus) {
|
||||||
if (!(await db.isDongsFresh(gu.name, CACHE_TTL_MS))) {
|
if (!(await db.isDongsFresh(gu.name, CACHE_TTL_MS))) stale.push(gu);
|
||||||
await db.replaceDongs(gu.name, await fetchDongs(gu.code));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const allDongs = await db.getAllDongs();
|
if (stale.length === 0) {
|
||||||
res.json(allDongs);
|
res.json(await db.getAllDongs());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
allDongsRefreshPromise = (async () => {
|
||||||
|
await Promise.all(stale.map((gu) => fetchDongs(gu.code).then((d) => db.replaceDongs(gu.name, d))));
|
||||||
|
return db.getAllDongs();
|
||||||
|
})().finally(() => {
|
||||||
|
allDongsRefreshPromise = null;
|
||||||
|
});
|
||||||
|
res.json(await allDongsRefreshPromise);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message });
|
res.status(500).json({ error: e.message });
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-23
@@ -1,4 +1,5 @@
|
|||||||
const VWORLD_ENDPOINT = 'https://api.vworld.kr/req/data';
|
const VWORLD_ENDPOINT = 'https://api.vworld.kr/req/data';
|
||||||
|
const VWORLD_ADDRESS_ENDPOINT = 'https://api.vworld.kr/req/address';
|
||||||
|
|
||||||
function credentials() {
|
function credentials() {
|
||||||
return {
|
return {
|
||||||
@@ -80,31 +81,18 @@ export async function reverseGeocode(lat, lng) {
|
|||||||
domain,
|
domain,
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await fetch(`${VWORLD_ENDPOINT}?${params}`);
|
const res = await fetch(`${VWORLD_ADDRESS_ENDPOINT}?${params}`);
|
||||||
if (!res.ok) throw new Error(`VWorld 지오코딩 실패 (HTTP ${res.status})`);
|
if (!res.ok) throw new Error(`VWorld 지오코딩 실패 (HTTP ${res.status})`);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
const result = json.response?.result;
|
const response = json.response;
|
||||||
|
if (response?.status !== 'OK') return { gu: '', dong: '' };
|
||||||
|
|
||||||
if (!result?.text) return { gu: '', dong: '' };
|
const first = Array.isArray(response.result) ? response.result[0] : response.result;
|
||||||
|
const structure = first?.structure;
|
||||||
|
if (!structure) return { gu: '', dong: '' };
|
||||||
|
|
||||||
const text = result.text;
|
return {
|
||||||
const structure = result.structure;
|
gu: structure.level2 || '',
|
||||||
|
dong: structure.level3 || structure.level4L || '',
|
||||||
let gu = '';
|
};
|
||||||
let dong = '';
|
|
||||||
|
|
||||||
if (structure) {
|
|
||||||
gu = structure.level1 || '';
|
|
||||||
dong = structure.level2 || '';
|
|
||||||
} else {
|
|
||||||
const parts = text.split(' ');
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
gu = parts[0];
|
|
||||||
dong = parts[1];
|
|
||||||
} else if (parts.length === 1) {
|
|
||||||
gu = parts[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { gu, dong };
|
|
||||||
}
|
}
|
||||||
|
|||||||
+130
-11
@@ -179,6 +179,17 @@
|
|||||||
.pole-list__title {
|
.pole-list__title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pole-list__header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pole-list__close {
|
.pole-list__close {
|
||||||
@@ -190,12 +201,6 @@
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pole-list__header-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pole-list__upload {
|
.pole-list__upload {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -212,6 +217,44 @@
|
|||||||
background-color: #f3f4f6;
|
background-color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pole-list__refresh {
|
||||||
|
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__refresh:hover:not(:disabled) {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pole-list__refresh:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pole-list__refresh-icon {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pole-refresh-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pole-list__refresh--loading .pole-list__refresh-icon {
|
||||||
|
animation: pole-refresh-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.pole-list__scroll {
|
.pole-list__scroll {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -325,6 +368,15 @@
|
|||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pole-list__filter-count {
|
||||||
|
margin-left: auto;
|
||||||
|
align-self: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #3b82f6;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.pole-list__area-filter {
|
.pole-list__area-filter {
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
@@ -472,6 +524,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.level-modal {
|
.level-modal {
|
||||||
|
position: relative;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -483,6 +536,38 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.level-modal__close {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background-color: rgba(0, 0, 0, 0.45);
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 28px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-modal__close:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-modal--busy .level-modal__label {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-modal--busy .level-modal__buttons,
|
||||||
|
.level-modal--busy .level-modal__close {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.level-modal__img {
|
.level-modal__img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 240px;
|
max-height: 240px;
|
||||||
@@ -491,6 +576,22 @@
|
|||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.level-modal__meta {
|
||||||
|
width: 100%;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-modal__meta-row {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.level-modal__label {
|
.level-modal__label {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -498,21 +599,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.level-modal__buttons {
|
.level-modal__buttons {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 12px;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-modal__btn {
|
.level-modal__btn {
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 14px 0;
|
padding: 12px 0;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -535,6 +636,24 @@
|
|||||||
background-color: #a855f7;
|
background-color: #a855f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.level-modal__btn--Ignore {
|
||||||
|
background-color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-modal__key {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.25);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.level-modal__desc {
|
.level-modal__desc {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
+289
-64
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import Map from 'ol/Map';
|
import OLMap from 'ol/Map';
|
||||||
import View from 'ol/View';
|
import View from 'ol/View';
|
||||||
import Overlay from 'ol/Overlay';
|
import Overlay from 'ol/Overlay';
|
||||||
import TileLayer from 'ol/layer/Tile';
|
import TileLayer from 'ol/layer/Tile';
|
||||||
@@ -17,6 +17,9 @@ import './App.css';
|
|||||||
const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY;
|
const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY;
|
||||||
const LEVEL_COLORS = { A: '#22c55e', B: '#f97316', Pole: '#a855f7' };
|
const LEVEL_COLORS = { A: '#22c55e', B: '#f97316', Pole: '#a855f7' };
|
||||||
const LEVEL_LABELS = { A: 'A', B: 'B', Pole: 'P' };
|
const LEVEL_LABELS = { A: 'A', B: 'B', Pole: 'P' };
|
||||||
|
const LEVEL_IGNORE = 'IGNORE';
|
||||||
|
const LEVEL_CANCEL = 'CANCEL';
|
||||||
|
const LEVEL_KEYS = { 1: 'A', 2: 'B', 3: 'Pole', 4: LEVEL_IGNORE };
|
||||||
|
|
||||||
function createTileSource(layer) {
|
function createTileSource(layer) {
|
||||||
return new XYZ({
|
return new XYZ({
|
||||||
@@ -46,6 +49,10 @@ function App() {
|
|||||||
const folderInputRef = useRef(null);
|
const folderInputRef = useRef(null);
|
||||||
const levelResolveRef = useRef(null);
|
const levelResolveRef = useRef(null);
|
||||||
const [levelModal, setLevelModal] = useState(null);
|
const [levelModal, setLevelModal] = useState(null);
|
||||||
|
const [modalAddress, setModalAddress] = useState(undefined);
|
||||||
|
const [modalBusy, setModalBusy] = useState(false);
|
||||||
|
const previewUrlRef = useRef(null);
|
||||||
|
const reverseGeocodeCacheRef = useRef(new Map());
|
||||||
const [levelFilter, setLevelFilter] = useState('all');
|
const [levelFilter, setLevelFilter] = useState('all');
|
||||||
const [guFilter, setGuFilter] = useState('용산구');
|
const [guFilter, setGuFilter] = useState('용산구');
|
||||||
const [dongFilter, setDongFilter] = useState('all');
|
const [dongFilter, setDongFilter] = useState('all');
|
||||||
@@ -56,6 +63,7 @@ function App() {
|
|||||||
const popupRef = useRef(null);
|
const popupRef = useRef(null);
|
||||||
const popupOverlayRef = useRef(null);
|
const popupOverlayRef = useRef(null);
|
||||||
const [popupPole, setPopupPole] = useState(null);
|
const [popupPole, setPopupPole] = useState(null);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const guList = useMemo(() => {
|
const guList = useMemo(() => {
|
||||||
const set = new Set(allDongs.map((d) => d.gu_name));
|
const set = new Set(allDongs.map((d) => d.gu_name));
|
||||||
@@ -135,6 +143,7 @@ function App() {
|
|||||||
if (levelFilter !== 'all' && pole.level !== levelFilter) return;
|
if (levelFilter !== 'all' && pole.level !== levelFilter) return;
|
||||||
if (guFilter !== 'all' && pole.gu !== guFilter) return;
|
if (guFilter !== 'all' && pole.gu !== guFilter) return;
|
||||||
if (dongFilter !== 'all' && pole.dong !== dongFilter) return;
|
if (dongFilter !== 'all' && pole.dong !== dongFilter) return;
|
||||||
|
if (timeFilterEnabled && pole.timestamp > timeFilterCutoff) return;
|
||||||
idSet.add(pole.id);
|
idSet.add(pole.id);
|
||||||
const feature = new Feature({
|
const feature = new Feature({
|
||||||
geometry: new Point(fromLonLat([pole.lng, pole.lat])),
|
geometry: new Point(fromLonLat([pole.lng, pole.lat])),
|
||||||
@@ -158,7 +167,7 @@ function App() {
|
|||||||
);
|
);
|
||||||
source.addFeature(feature);
|
source.addFeature(feature);
|
||||||
});
|
});
|
||||||
}, [poles, levelFilter, guFilter, dongFilter]);
|
}, [poles, levelFilter, guFilter, dongFilter, timeFilterEnabled, timeFilterCutoff]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
syncMarkers();
|
syncMarkers();
|
||||||
@@ -179,6 +188,23 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshPoles = useCallback(async () => {
|
||||||
|
if (refreshing) return;
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/poles');
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const next = await res.json();
|
||||||
|
polesRef.current = next;
|
||||||
|
setPoles(next);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('전신주 목록 갱신 실패:', err);
|
||||||
|
alert('목록 갱신에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [refreshing]);
|
||||||
|
|
||||||
const flyTo = useCallback((lng, lat) => {
|
const flyTo = useCallback((lng, lat) => {
|
||||||
if (!mapRef.current) return;
|
if (!mapRef.current) return;
|
||||||
mapRef.current.getView().animate({
|
mapRef.current.getView().animate({
|
||||||
@@ -198,25 +224,105 @@ function App() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const requestLevel = useCallback((file, gpsData) => {
|
const lookupAddress = useCallback(async (lat, lng) => {
|
||||||
const previewUrl = URL.createObjectURL(file);
|
if (!(reverseGeocodeCacheRef.current instanceof Map)) {
|
||||||
return new Promise((resolve) => {
|
reverseGeocodeCacheRef.current = new Map();
|
||||||
levelResolveRef.current = (level) => {
|
}
|
||||||
URL.revokeObjectURL(previewUrl);
|
const key = `${Number(lat).toFixed(5)},${Number(lng).toFixed(5)}`;
|
||||||
resolve(level);
|
if (reverseGeocodeCacheRef.current.has(key)) {
|
||||||
};
|
return reverseGeocodeCacheRef.current.get(key);
|
||||||
setLevelModal({ file, previewUrl, gpsData });
|
}
|
||||||
});
|
try {
|
||||||
}, []);
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 8000);
|
||||||
const handleLevelSelect = useCallback((level) => {
|
const res = await fetch(`/api/reverse-geocode?lat=${lat}&lng=${lng}`, {
|
||||||
setLevelModal(null);
|
signal: controller.signal,
|
||||||
if (levelResolveRef.current) {
|
});
|
||||||
levelResolveRef.current(level);
|
clearTimeout(timer);
|
||||||
levelResolveRef.current = null;
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const geo = await res.json();
|
||||||
|
const label = [geo.gu, geo.dong].filter(Boolean).join(' ') || null;
|
||||||
|
reverseGeocodeCacheRef.current.set(key, label);
|
||||||
|
return label;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('역지오코딩 실패:', err);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const claimPreviewUrl = useCallback((file, preloadedUrl) => {
|
||||||
|
const url = preloadedUrl || URL.createObjectURL(file);
|
||||||
|
if (previewUrlRef.current && previewUrlRef.current !== url) {
|
||||||
|
URL.revokeObjectURL(previewUrlRef.current);
|
||||||
|
}
|
||||||
|
previewUrlRef.current = url;
|
||||||
|
return url;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeLevelModal = useCallback(() => {
|
||||||
|
levelResolveRef.current = null;
|
||||||
|
if (previewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(previewUrlRef.current);
|
||||||
|
previewUrlRef.current = null;
|
||||||
|
}
|
||||||
|
setLevelModal(null);
|
||||||
|
setModalAddress(undefined);
|
||||||
|
setModalBusy(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestLevel = useCallback((info) => {
|
||||||
|
const previewUrl = claimPreviewUrl(info.file, info.previewUrl);
|
||||||
|
setModalBusy(false);
|
||||||
|
setLevelModal({
|
||||||
|
file: info.file,
|
||||||
|
previewUrl,
|
||||||
|
lat: info.lat,
|
||||||
|
lng: info.lng,
|
||||||
|
timestamp: info.timestamp,
|
||||||
|
});
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
levelResolveRef.current = resolve;
|
||||||
|
});
|
||||||
|
}, [claimPreviewUrl]);
|
||||||
|
|
||||||
|
const handleLevelSelect = useCallback(
|
||||||
|
(level) => {
|
||||||
|
if (modalBusy || !levelResolveRef.current) return;
|
||||||
|
levelResolveRef.current(level);
|
||||||
|
levelResolveRef.current = null;
|
||||||
|
setModalBusy(true);
|
||||||
|
},
|
||||||
|
[modalBusy],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!levelModal) return;
|
||||||
|
const onKeyDown = (e) => {
|
||||||
|
const level = LEVEL_KEYS[e.key];
|
||||||
|
if (level) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleLevelSelect(level);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [levelModal, handleLevelSelect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!levelModal) {
|
||||||
|
setModalAddress(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setModalAddress(undefined);
|
||||||
|
lookupAddress(levelModal.lat, levelModal.lng).then((address) => {
|
||||||
|
if (!cancelled) setModalAddress(address);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [levelModal, lookupAddress]);
|
||||||
|
|
||||||
const refreshGps = useCallback(() => {
|
const refreshGps = useCallback(() => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
@@ -254,7 +360,7 @@ function App() {
|
|||||||
const markerLayer = new VectorLayer({ source: markerSource });
|
const markerLayer = new VectorLayer({ source: markerSource });
|
||||||
markerLayerRef.current = markerLayer;
|
markerLayerRef.current = markerLayer;
|
||||||
|
|
||||||
const map = new Map({
|
const map = new OLMap({
|
||||||
target: mapContainerRef.current,
|
target: mapContainerRef.current,
|
||||||
layers: [
|
layers: [
|
||||||
new TileLayer({ source: createTileSource('Base') }),
|
new TileLayer({ source: createTileSource('Base') }),
|
||||||
@@ -378,6 +484,10 @@ function App() {
|
|||||||
markerLayerRef.current = null;
|
markerLayerRef.current = null;
|
||||||
currentLocationRef.current = null;
|
currentLocationRef.current = null;
|
||||||
fallbackUsedRef.current = false;
|
fallbackUsedRef.current = false;
|
||||||
|
if (previewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(previewUrlRef.current);
|
||||||
|
previewUrlRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -427,8 +537,14 @@ function App() {
|
|||||||
|
|
||||||
setGpsInfo({ source, lat, lng });
|
setGpsInfo({ source, lat, lng });
|
||||||
|
|
||||||
const level = await requestLevel(file, { lat, lng });
|
const level = await requestLevel({
|
||||||
if (!level) {
|
file,
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
timestamp: exifTimestamp || new Date().toISOString(),
|
||||||
|
});
|
||||||
|
closeLevelModal();
|
||||||
|
if (!level || level === LEVEL_IGNORE || level === LEVEL_CANCEL) {
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
return;
|
return;
|
||||||
@@ -488,57 +604,102 @@ function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preparePhoto = async (file) => {
|
||||||
|
let lat;
|
||||||
|
let lng;
|
||||||
|
let exifTimestamp = null;
|
||||||
|
try {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const exifData = await exifr.parse(buffer);
|
||||||
|
if (exifData && exifData.latitude && exifData.longitude) {
|
||||||
|
lat = exifData.latitude;
|
||||||
|
lng = exifData.longitude;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
const previewUrl = URL.createObjectURL(file);
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve();
|
||||||
|
img.onerror = () => reject(new Error('preview decode failed'));
|
||||||
|
img.src = previewUrl;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 디코딩 미지원 형식(예: HEIC)은 모달에서 브라우저가 다시 시도한다.
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
exifTimestamp,
|
||||||
|
timestamp: exifTimestamp || new Date().toISOString(),
|
||||||
|
previewUrl,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
setUploadProgress({ current: 0, total: imageFiles.length });
|
setUploadProgress({ current: 0, total: imageFiles.length });
|
||||||
|
|
||||||
let registered = 0;
|
let registered = 0;
|
||||||
|
let ignoredCount = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
let duplicated = 0;
|
let duplicated = 0;
|
||||||
|
let cancelledRemaining = 0;
|
||||||
|
|
||||||
|
let pendingPrepPromise = preparePhoto(imageFiles[0]);
|
||||||
|
|
||||||
for (let i = 0; i < imageFiles.length; i++) {
|
for (let i = 0; i < imageFiles.length; i++) {
|
||||||
const file = imageFiles[i];
|
|
||||||
setUploadProgress({ current: i + 1, total: imageFiles.length });
|
setUploadProgress({ current: i + 1, total: imageFiles.length });
|
||||||
|
|
||||||
|
let prep = null;
|
||||||
try {
|
try {
|
||||||
let lat;
|
prep = await pendingPrepPromise;
|
||||||
let lng;
|
} catch (err) {
|
||||||
let exifTimestamp = null;
|
console.error('사진 준비 실패:', imageFiles[i].name, err);
|
||||||
|
}
|
||||||
|
pendingPrepPromise =
|
||||||
|
i + 1 < imageFiles.length ? preparePhoto(imageFiles[i + 1]) : null;
|
||||||
|
|
||||||
try {
|
if (!prep) {
|
||||||
const buffer = await file.arrayBuffer();
|
skipped++;
|
||||||
const exifData = await exifr.parse(buffer);
|
continue;
|
||||||
if (exifData && exifData.latitude && exifData.longitude) {
|
}
|
||||||
lat = exifData.latitude;
|
if (prep.lat !== undefined) {
|
||||||
lng = exifData.longitude;
|
lastPositionRef.current = { lat: prep.lat, lng: prep.lng };
|
||||||
lastPositionRef.current = { lat, lng };
|
} else {
|
||||||
}
|
const last = lastPositionRef.current;
|
||||||
if (exifData && exifData.DateTimeOriginal) {
|
if (!last) {
|
||||||
exifTimestamp = new Date(exifData.DateTimeOriginal).toISOString();
|
URL.revokeObjectURL(prep.previewUrl);
|
||||||
} else if (exifData && exifData.DateTime) {
|
|
||||||
exifTimestamp = new Date(exifData.DateTime).toISOString();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('EXIF 읽기 실패:', file.name, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lat === undefined) {
|
|
||||||
const last = lastPositionRef.current;
|
|
||||||
if (last) {
|
|
||||||
lat = last.lat;
|
|
||||||
lng = last.lng;
|
|
||||||
} else {
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const level = await requestLevel(file, { lat, lng });
|
|
||||||
if (!level) {
|
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
prep.lat = last.lat;
|
||||||
|
prep.lng = last.lng;
|
||||||
|
}
|
||||||
|
|
||||||
const compressed = await imageCompression(file, {
|
const level = await requestLevel(prep);
|
||||||
|
if (!level) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (level === LEVEL_IGNORE) {
|
||||||
|
ignoredCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (level === LEVEL_CANCEL) {
|
||||||
|
cancelledRemaining = imageFiles.length - i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const compressed = await imageCompression(prep.file, {
|
||||||
maxSizeMB: 0.5,
|
maxSizeMB: 0.5,
|
||||||
maxWidthOrHeight: 800,
|
maxWidthOrHeight: 800,
|
||||||
useWebWorker: true,
|
useWebWorker: true,
|
||||||
@@ -549,9 +710,9 @@ function App() {
|
|||||||
formData.append('level', level);
|
formData.append('level', level);
|
||||||
formData.append('photo', compressed, `${id}.jpg`);
|
formData.append('photo', compressed, `${id}.jpg`);
|
||||||
formData.append('id', id);
|
formData.append('id', id);
|
||||||
formData.append('lat', lat);
|
formData.append('lat', prep.lat);
|
||||||
formData.append('lng', lng);
|
formData.append('lng', prep.lng);
|
||||||
formData.append('timestamp', exifTimestamp || new Date().toISOString());
|
formData.append('timestamp', prep.timestamp);
|
||||||
|
|
||||||
const res = await fetch('/api/poles', { method: 'POST', body: formData });
|
const res = await fetch('/api/poles', { method: 'POST', body: formData });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -570,16 +731,30 @@ function App() {
|
|||||||
});
|
});
|
||||||
registered++;
|
registered++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('전신주 등록 실패:', file.name, err);
|
console.error('전신주 등록 실패:', prep.file.name, err);
|
||||||
skipped++;
|
skipped++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pendingPrepPromise) {
|
||||||
|
try {
|
||||||
|
const leftover = await pendingPrepPromise;
|
||||||
|
if (leftover?.previewUrl && leftover.previewUrl !== previewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(leftover.previewUrl);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 준비 자체가 실패한 경우 정리할 자원이 없다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeLevelModal();
|
||||||
|
|
||||||
setUploadProgress(null);
|
setUploadProgress(null);
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
const parts = [`등록: ${registered}장`];
|
const parts = [`등록: ${registered}장`];
|
||||||
|
if (ignoredCount > 0) parts.push(`무시: ${ignoredCount}장`);
|
||||||
if (duplicated > 0) parts.push(`중복: ${duplicated}장`);
|
if (duplicated > 0) parts.push(`중복: ${duplicated}장`);
|
||||||
if (skipped > 0) parts.push(`건너뜀: ${skipped}장`);
|
if (skipped > 0) parts.push(`건너뜀: ${skipped}장`);
|
||||||
|
if (cancelledRemaining > 0) parts.push(`취소: ${cancelledRemaining}장`);
|
||||||
alert(parts.join(' / '));
|
alert(parts.join(' / '));
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
};
|
};
|
||||||
@@ -665,34 +840,75 @@ function App() {
|
|||||||
|
|
||||||
{levelModal && (
|
{levelModal && (
|
||||||
<div className="level-modal-backdrop" onClick={() => handleLevelSelect(null)}>
|
<div className="level-modal-backdrop" onClick={() => handleLevelSelect(null)}>
|
||||||
<div className="level-modal" onClick={(e) => e.stopPropagation()}>
|
<div
|
||||||
|
className={`level-modal${modalBusy ? ' level-modal--busy' : ''}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="level-modal__close"
|
||||||
|
aria-label="등록 작업 취소"
|
||||||
|
title="작업 취소"
|
||||||
|
disabled={modalBusy}
|
||||||
|
onClick={() => handleLevelSelect(LEVEL_CANCEL)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
<img src={levelModal.previewUrl} alt="전신주 사진" className="level-modal__img" />
|
<img src={levelModal.previewUrl} alt="전신주 사진" className="level-modal__img" />
|
||||||
<div className="level-modal__label">전신주 등급을 선택하세요</div>
|
<div className="level-modal__meta">
|
||||||
|
<div className="level-modal__meta-row">
|
||||||
|
📍 {modalAddress === undefined ? '주소 조회 중...' : modalAddress || '주소를 찾을 수 없음'}
|
||||||
|
</div>
|
||||||
|
{levelModal.timestamp && (
|
||||||
|
<div className="level-modal__meta-row">
|
||||||
|
🕒 {new Date(levelModal.timestamp).toLocaleString('ko-KR')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="level-modal__label">
|
||||||
|
{modalBusy ? '처리 중...' : '전신주 등급을 선택하세요'}
|
||||||
|
</div>
|
||||||
<div className="level-modal__buttons">
|
<div className="level-modal__buttons">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="level-modal__btn level-modal__btn--A"
|
className="level-modal__btn level-modal__btn--A"
|
||||||
|
disabled={modalBusy}
|
||||||
onClick={() => handleLevelSelect('A')}
|
onClick={() => handleLevelSelect('A')}
|
||||||
>
|
>
|
||||||
|
<span className="level-modal__key">1</span>
|
||||||
A
|
A
|
||||||
<span className="level-modal__desc">양호</span>
|
<span className="level-modal__desc">양호</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="level-modal__btn level-modal__btn--B"
|
className="level-modal__btn level-modal__btn--B"
|
||||||
|
disabled={modalBusy}
|
||||||
onClick={() => handleLevelSelect('B')}
|
onClick={() => handleLevelSelect('B')}
|
||||||
>
|
>
|
||||||
|
<span className="level-modal__key">2</span>
|
||||||
B
|
B
|
||||||
<span className="level-modal__desc">불량</span>
|
<span className="level-modal__desc">불량</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="level-modal__btn level-modal__btn--Pole"
|
className="level-modal__btn level-modal__btn--Pole"
|
||||||
|
disabled={modalBusy}
|
||||||
onClick={() => handleLevelSelect('Pole')}
|
onClick={() => handleLevelSelect('Pole')}
|
||||||
>
|
>
|
||||||
|
<span className="level-modal__key">3</span>
|
||||||
Pole
|
Pole
|
||||||
<span className="level-modal__desc">기준</span>
|
<span className="level-modal__desc">기준</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="level-modal__btn level-modal__btn--Ignore"
|
||||||
|
disabled={modalBusy}
|
||||||
|
onClick={() => handleLevelSelect(LEVEL_IGNORE)}
|
||||||
|
>
|
||||||
|
<span className="level-modal__key">4</span>
|
||||||
|
무시
|
||||||
|
<span className="level-modal__desc">등록 안 함</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -704,7 +920,7 @@ function App() {
|
|||||||
onClick={() => setListOpen((o) => !o)}
|
onClick={() => setListOpen((o) => !o)}
|
||||||
className="list-toggle"
|
className="list-toggle"
|
||||||
>
|
>
|
||||||
📋 {filteredPoles.length}기
|
📋 {poles.length}기
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -740,9 +956,17 @@ function App() {
|
|||||||
>
|
>
|
||||||
<div className="pole-list__header">
|
<div className="pole-list__header">
|
||||||
<span className="pole-list__title">
|
<span className="pole-list__title">
|
||||||
전신주 목록 ({filteredPoles.length}기)
|
전신주 목록 ({poles.length}기)
|
||||||
</span>
|
</span>
|
||||||
<div className="pole-list__header-actions">
|
<div className="pole-list__header-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`pole-list__refresh${refreshing ? ' pole-list__refresh--loading' : ''}`}
|
||||||
|
onClick={refreshPoles}
|
||||||
|
disabled={refreshing}
|
||||||
|
>
|
||||||
|
<span className="pole-list__refresh-icon">🔄</span> 리프레시
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="pole-list__upload"
|
className="pole-list__upload"
|
||||||
@@ -811,6 +1035,7 @@ function App() {
|
|||||||
{f.label}
|
{f.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<span className="pole-list__filter-count">{filteredPoles.length}기</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pole-list__area-filter">
|
<div className="pole-list__area-filter">
|
||||||
|
|||||||
Reference in New Issue
Block a user