본문으로 건너뛰기
Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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:'&copy; 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