Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
public/js/hinana-rail.js
공개 가능
1
(() => {
2
const $ = id => document.getElementById(id), form = $('trip-form'), editor = $('editor');
3
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
4
let trips = [], map, layer, preview, busy = false, lookupTimer, lookupVersion = 0;
5
const automatic = {};
6
const detail = $('trip-detail');
7
let detailTrip = null, ticketVersion = 0, ticketReady = false;
8
let ticketBrand;
9
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
10
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
11
const money = value => Number(value).toLocaleString('ko-KR');
12
async function request(url, options = {}) {
13
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
14
if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.message || '요청에 실패했습니다. 다시 로그인하거나 잠시 후 시도해 주세요.'); }
15
return response.status === 204 ? null : response.json();
16
}
17
const counts = values => [...values.reduce((m, key) => m.set(key, (m.get(key) || 0) + 1), new Map())].sort((a,b) => b[1]-a[1]);
18
function rows(id, values) { $(id).innerHTML = values.length ? values.map(([key,n]) => `<div class="count-row"><span>${escape(key)}</span><strong>${n}회</strong></div>`).join('') : '<p class="empty">아직 기록이 없습니다.</p>'; }
19
function render() {
20
const dateParts = Object.fromEntries(new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Seoul',year:'numeric',month:'2-digit',day:'2-digit'}).formatToParts(new Date()).map(p=>[p.type,p.value]));
21
const today = `${dateParts.year}-${dateParts.month}-${dateParts.day}`;
22
const completed = trips.filter(t => t.date <= today);
23
const stations = new Set(completed.flatMap(t => [t.departure,t.arrival]));
24
const distanceKnown = completed.filter(t => t.distance !== null);
25
$('stats').innerHTML = `<div>승차 기록<strong>${completed.length}회</strong></div><div>누적 거리<strong>${money(distanceKnown.reduce((n,t)=>n+t.distance,0))} km</strong><small>거리 입력 ${distanceKnown.length} / ${completed.length}건</small></div><div>방문역<strong>${stations.size}곳</strong></div><div>누적 결제<strong>${money(completed.reduce((n,t)=>n+(t.fare||0),0))}원</strong></div>`;
26
const query = $('search').value.trim().toLowerCase();
27
const filtered = trips.filter(t => [t.departure,t.arrival,t.trainType,t.line].join(' ').toLowerCase().includes(query)).sort((a,b)=>(b.date+b.departureTime).localeCompare(a.date+a.departureTime));
28
$('trips').innerHTML = filtered.length ? filtered.map(t => `<article class="trip"><div class="head"><div><small>${escape(t.date)} ${t.date > today ? ' · 예정' : ''}</small><div class="trip-title"><button type="button" class="trip-open" data-view="${t.id}" title="승차 기록 상세보기">${escape(t.departure)} ${escape(t.departureTime)} → ${escape(t.arrival)} ${escape(t.arrivalTime)}</button></div></div><div><button class="btn icon" data-edit="${t.id}" title="수정" aria-label="수정"><i class="bi bi-pencil"></i></button> <button class="btn icon" data-delete="${t.id}" title="삭제" aria-label="삭제"><i class="bi bi-trash"></i></button></div></div><div class="trip-meta"><span>${escape(t.trainType)} ${escape(t.trainNumber)}</span><span>${escape(t.seatClass)} ${escape(t.seat)}</span><span>${t.fare === null ? '금액 미입력' : money(t.fare)+'원'}</span><span>${t.distance === null ? '' : money(t.distance)+' km'}</span></div><p class="note">${escape(t.note)}</p><div class="photos">${t.photos.map(p=>`<a href="/hinana/rail/photos/${encodeURIComponent(p)}" target="_blank" rel="noopener"><img loading="lazy" src="/hinana/rail/photos/${encodeURIComponent(p)}" alt="여행 사진"></a>`).join('')}</div></article>`).join('') : '<p class="empty">승차 기록이 없습니다.</p>';
29
$('fleet').innerHTML = collectionTypes.map(type => { const n = completed.filter(t=>t.trainType===type).length; return `<article class="vehicle ${n?'done':''}"><i class="bi bi-train-front"></i><h3>${escape(type)}</h3><strong>${n?'탑승 완료 · '+n+'회':'미탑승'}</strong></article>`; }).join('');
30
rows('lines',counts(completed.map(t=>t.line||'노선 미입력')));
31
rows('segments',counts(completed.map(t=>t.departure+' → '+t.arrival)));
32
rows('stations',counts(completed.flatMap(t=>[t.departure,t.arrival])));
33
drawMap(completed);
34
}
35
// Approximate station positions for the schematic, never used to calculate rail distance.
36
const positions = {'서울':[37.555,126.971],'용산':[37.530,126.965],'수서':[37.485,127.105],'동탄':[37.200,127.095],'평택지제':[37.018,127.070],'천안아산':[36.794,127.105],'오송':[36.620,127.327],'대전':[36.332,127.435],'김천구미':[36.113,128.181],'동대구':[35.879,128.629],'경주':[35.798,129.138],'울산':[35.552,129.138],'부산':[35.115,129.042],'익산':[35.940,126.947],'정읍':[35.576,126.842],'광주송정':[35.138,126.791],'목포':[34.791,126.386],'전주':[35.850,127.162],'여수엑스포':[34.752,127.748],'청량리':[37.580,127.047],'강릉':[37.764,128.899],'안동':[36.565,128.700]};
37
function drawMap(completed) {
38
if ($('routes').hidden) return;
39
if (!window.L) { $('map').textContent = '지도를 불러오지 못했습니다. 네트워크 연결을 확인해 주세요.'; return; }
40
if (!map) { map = L.map('map').setView([36.2,127.8],7); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'© OpenStreetMap contributors',maxZoom:18}).addTo(map); layer = L.layerGroup().addTo(map); }
41
layer.clearLayers(); const points = [], missing = new Set();
42
completed.forEach(t=>{const a=positions[t.departure.replace(/역$/,'')], b=positions[t.arrival.replace(/역$/,'')]; if(!a) missing.add(t.departure); if(!b) missing.add(t.arrival); if(a&&b){L.polyline([a,b],{color:'#087c79',weight:3,dashArray:'6 6'}).addTo(layer);points.push(a,b);} [ [a,t.departure], [b,t.arrival] ].forEach(([p,name])=>{if(p) L.circleMarker(p,{radius:5,color:'#246ac0'}).bindTooltip(document.createTextNode(name)).addTo(layer);});});
43
map.invalidateSize(); if(points.length) map.fitBounds(points,{padding:[25,25],maxZoom:10});
44
$('unmapped').textContent = missing.size ? '지도 미등록 역: '+[...missing].join(', ') : '';
45
}
46
async function load() { trips = await request('/trips'); render(); }
47
function showDetail(trip) {
48
if (!trip || busy) return;
49
detailTrip = trip; ticketVersion++; ticketReady = false;
50
$('souvenir').hidden = true; $('ticket-message').textContent = '';
51
$('create-ticket').disabled = false; $('download-ticket').disabled = true;
52
const fields = [['이용 날짜',trip.date],['차종 · 열차 번호',[trip.trainType,trip.trainNumber].filter(Boolean).join(' ')],['좌석 등급',trip.seatClass],['호차 · 좌석',trip.seat],['노선',trip.line],['승차 거리',trip.distance===null?'':money(trip.distance)+' km'],['결제 금액',trip.fare===null?'':money(trip.fare)+'원']];
53
$('detail-content').innerHTML = `<p class="detail-route">${escape(trip.departure)} ${escape(trip.departureTime)} → ${escape(trip.arrival)} ${escape(trip.arrivalTime)}</p><dl class="detail-fields">${fields.map(([label,value])=>`<div><dt>${label}</dt><dd>${escape(value||'미기록')}</dd></div>`).join('')}</dl>${trip.note?`<p class="detail-note">${escape(trip.note)}</p>`:''}<div class="photos">${trip.photos.map(p=>`<a href="/hinana/rail/photos/${encodeURIComponent(p)}" target="_blank" rel="noopener"><img src="/hinana/rail/photos/${encodeURIComponent(p)}" alt="여행 사진"></a>`).join('')}</div>`;
54
detail.showModal();
55
}
56
$('close-detail').onclick = () => detail.close();
57
detail.addEventListener('close', () => { ticketVersion++; ticketReady=false; detailTrip=null; });
58
$('edit-detail').onclick = () => { const trip=detailTrip; detail.close(); open(trip); };
59
$('create-ticket').onclick = async () => {
60
if (!detailTrip) return;
61
const version=++ticketVersion, trip=detailTrip;
62
$('create-ticket').disabled=true; $('ticket-message').textContent='';
63
try {
64
if(document.fonts)await document.fonts.ready;
65
if(!ticketBrand)ticketBrand=new Promise(resolve=>{
66
const image=new Image(), timeout=setTimeout(()=>resolve(null),5000);
67
image.onload=()=>{clearTimeout(timeout);resolve(image);};
68
image.onerror=()=>{clearTimeout(timeout);resolve(null);};
69
image.src='/image/archive.png';
70
});
71
const brand=await ticketBrand;
72
if(version!==ticketVersion || !detail.open)return;
73
window.HinanaRailTicket.render($('ticket-canvas'),trip,brand);
74
$('ticket-caption').textContent=window.HinanaRailTicket.summary(trip);
75
$('souvenir').hidden=false; ticketReady=true; $('download-ticket').disabled=false;
76
$('souvenir').scrollIntoView({block:'nearest'});
77
} catch(e) { if(version===ticketVersion)$('ticket-message').textContent='기념 승차권을 생성하지 못했습니다. 다시 시도해 주세요.'; }
78
finally { if(version===ticketVersion)$('create-ticket').disabled=false; }
79
};
80
$('download-ticket').onclick = async () => {
81
if(!ticketReady || !detailTrip)return;
82
const version=ticketVersion, trip=detailTrip;
83
$('download-ticket').disabled=true; $('ticket-message').textContent='';
84
try {
85
const blob=await new Promise(resolve=>$('ticket-canvas').toBlob(resolve,'image/png'));
86
if(version!==ticketVersion)return;
87
if(!blob)throw new Error('PNG encoding failed');
88
const url=URL.createObjectURL(blob), link=document.createElement('a');
89
link.href=url; link.download=window.HinanaRailTicket.filename(trip);
90
document.body.appendChild(link); link.click(); link.remove();
91
setTimeout(()=>URL.revokeObjectURL(url),60000);
92
} catch(e) { if(version===ticketVersion)$('ticket-message').textContent='이미지를 저장하지 못했습니다. 다시 시도해 주세요.'; }
93
finally { if(version===ticketVersion)$('download-ticket').disabled=false; }
94
};
95
function open(trip) { if(busy)return; clearTimeout(lookupTimer); lookupVersion++; Object.keys(automatic).forEach(k=>delete automatic[k]); $('route-message').textContent=''; $('route-source').hidden=true; form.reset(); form.elements.id.value=''; $('save-message').textContent=''; $('draft-message').textContent=''; $('source-preview').hidden=true; if(preview) URL.revokeObjectURL(preview); form.elements.trainType.value='KTX (미확인)'; if(trip) Object.keys(trip).forEach(k=>{if(form.elements[k] && k!=='photos')form.elements[k].value=trip[k]??'';}); $('editor-title').textContent=trip?'승차 기록 수정':'새 승차 기록'; editor.showModal(); }
96
const lookupKeys = ['date','trainType','trainNumber','departure','arrival','departureTime'];
97
function lookupInput() { return new URLSearchParams(lookupKeys.map(k=>[k,form.elements[k].value.trim()])); }
98
async function lookupRoute(explicit = false) {
99
clearTimeout(lookupTimer);
100
const version=++lookupVersion, params=lookupInput(), signature=params.toString();
101
if(!['date','trainNumber','departure','arrival'].every(k=>params.get(k))) { if(explicit)$('route-message').textContent='이용 날짜, 열차 번호, 출발·도착역을 입력해 주세요.'; return; }
102
$('route-message').textContent='노선과 거리를 확인하고 있습니다…'; $('route-source').hidden=true;
103
try {
104
const result=await request('/route?'+signature);
105
if(version!==lookupVersion || lookupInput().toString()!==signature)return;
106
if(result.matched) for(const [key,value] of Object.entries(result.draft)) {
107
if(value!==null && (!form.elements[key].value || form.elements[key].value===automatic[key])) {
108
form.elements[key].value=value; automatic[key]=String(value);
109
}
110
}
111
$('route-message').textContent=result.message;
112
if(result.source) { $('route-source').href=result.source.url; $('route-source').title=result.source.title; $('route-source').hidden=false; }
113
} catch(e) { if(version===lookupVersion)$('route-message').textContent=e.message; }
114
}
115
function scheduleLookup() {
116
lookupVersion++; clearTimeout(lookupTimer); $('route-message').textContent=''; $('route-source').hidden=true;
117
for(const key of Object.keys(automatic)) { if(form.elements[key].value===automatic[key])form.elements[key].value=''; delete automatic[key]; }
118
lookupTimer=setTimeout(()=>lookupRoute(),500);
119
}
120
$('lookup-route').onclick=()=>lookupRoute(true);
121
lookupKeys.forEach(k=>form.elements[k].addEventListener('input',scheduleLookup));
122
$('new-trip').onclick=()=>open(); $('close-editor').onclick=$('cancel').onclick=()=>{if(!busy)editor.close();}; editor.addEventListener('cancel',e=>{if(busy)e.preventDefault();});
123
$('receipt').onchange=()=>{if(preview)URL.revokeObjectURL(preview);const f=$('receipt').files[0];$('source-preview').hidden=!f;if(f){preview=URL.createObjectURL(f);$('source-preview').src=preview;}};
124
$('recognize').onclick=async()=>{
125
const file=$('receipt').files[0];
126
if(!file){$('draft-message').textContent='사진을 선택해 주세요.';return;}
127
if(file.size>8*1024*1024){$('draft-message').textContent='8MB 이하 사진을 선택해 주세요.';return;}
128
busy=true;$('recognize').disabled=$('save').disabled=true;$('draft-message').textContent='영수증을 읽고 있습니다…';
129
try{
130
const body=await file.arrayBuffer();
131
if(!body.byteLength || body.byteLength!==file.size)throw new Error('선택한 사진을 끝까지 읽지 못했습니다. 사진을 기기에 저장한 뒤 다시 선택해 주세요.');
132
const result=await request('/recognize',{method:'POST',headers:{'content-type':'application/octet-stream'},body});
133
Object.entries(result.draft).forEach(([key,value])=>{if(form.elements[key])form.elements[key].value=value;});
134
$('draft-message').textContent=['인식 결과를 확인해 주세요. 빈 항목은 확인되지 않은 정보입니다.',...result.warnings].join('\n');
135
scheduleLookup();
136
}catch(e){$('draft-message').textContent=e.message;}
137
finally{busy=false;$('recognize').disabled=$('save').disabled=false;}
138
};
139
form.onsubmit=async e=>{e.preventDefault();if(busy)return;busy=true;$('save').disabled=$('recognize').disabled=true;$('save-message').textContent='';try{if(!form.elements.line.value || !form.elements.distance.value)await lookupRoute();await request('/trips',{method:'POST',body:new FormData(form)});editor.close();await load();}catch(e){$('save-message').textContent=e.message;$('message').textContent=e.message;}finally{busy=false;$('save').disabled=$('recognize').disabled=false;}};
140
$('trips').onclick=async e=>{const view=e.target.closest('[data-view]'),edit=e.target.closest('[data-edit]'),del=e.target.closest('[data-delete]');if(view)showDetail(trips.find(t=>t.id===view.dataset.view));if(edit)open(trips.find(t=>t.id===edit.dataset.edit));if(del&&confirm('승차 기록과 첨부 사진을 삭제할까요?')){del.disabled=true;try{await request('/trips/'+del.dataset.delete,{method:'DELETE'});await load();}catch(e){$('message').textContent=e.message;del.disabled=false;}}};
141
$('search').oninput=render;
142
document.querySelectorAll('[data-tab]').forEach(button=>button.onclick=()=>{document.querySelectorAll('[data-tab]').forEach(b=>{const active=b===button;b.classList.toggle('active',active);$(b.dataset.tab).hidden=!active;});render();});
143
load().catch(e=>$('message').textContent=e.message);
144
})();
145