Files
chpark dcae228a24 fix: 전 페이지 서버사이드 렌더링으로 전환 (초기 데이터 fetch 제거)
- 모든 관리자 페이지에서 DB 데이터를 서버에서 직접 HTML에 주입
- __INIT__ 글로벌 변수로 초기 데이터 전달 (fetch 불필요)
- 대시보드/사이트관리/AdSense/도메인/로그/사이트상세 전부 적용
- trust proxy 설정 (Traefik 뒤 동작)
- 저장/삭제/크롤링 등 액션은 여전히 API fetch 사용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:24:57 +09:00

97 lines
5.5 KiB
Plaintext

<%- include('layout', { page: 'sites', pageTitle: '사이트 상세', body: `
<div id="site-info"></div>
<div class="card">
<div class="card-header"><h2>크롤링 스케줄</h2></div>
<div class="form-row">
<div class="form-group">
<label>크론 표현식</label>
<input id="cron-expr" placeholder="0 6 * * *">
<div class="cron-presets">
<span class="preset" onclick="setCron('*/5 * * * *')">5분마다</span>
<span class="preset" onclick="setCron('0 * * * *')">매시간</span>
<span class="preset" onclick="setCron('0 */6 * * *')">6시간마다</span>
<span class="preset" onclick="setCron('0 6 * * *')">매일 06시</span>
<span class="preset" onclick="setCron('0 6,12,18 * * *')">하루 3회</span>
<span class="preset" onclick="setCron('0 0 * * *')">매일 자정</span>
</div>
</div>
<div class="form-group">
<label>스케줄 활성화</label>
<div style="margin-top:.5rem"><label style="display:inline-flex;align-items:center;gap:.5rem;cursor:pointer"><input type="checkbox" id="sched-active" style="width:auto"><span>활성화</span></label></div>
</div>
</div>
<button class="btn btn-primary" onclick="saveSchedule()">스케줄 저장</button>
<button class="btn btn-success" onclick="crawlNow()" style="margin-left:.5rem">지금 크롤링</button>
</div>
<div class="card">
<div class="card-header"><h2>크롤링 결과</h2></div>
<table>
<thead><tr><th>ID</th><th>시간</th><th>상태</th><th>항목 수</th><th>에러</th><th>액션</th></tr></thead>
<tbody id="results-tbody"></tbody>
</table>
</div>
<div class="modal-overlay" id="previewModal">
<div class="modal" style="max-width:900px">
<div class="flex-between mb-1">
<h3>미리보기</h3>
<button class="btn btn-outline btn-sm" onclick="document.getElementById('previewModal').classList.remove('active')">닫기</button>
</div>
<div id="preview-tabs" class="flex mb-1">
<button class="btn btn-sm btn-primary" onclick="showTab('rendered')">렌더링</button>
<button class="btn btn-sm btn-outline" onclick="showTab('parsed')">파싱 데이터</button>
<button class="btn btn-sm btn-outline" onclick="showTab('raw')">원본 HTML</button>
</div>
<div id="tab-rendered"><iframe id="preview-iframe" style="width:100%;height:500px;border:1px solid var(--border);border-radius:var(--radius);background:#fff"></iframe></div>
<div id="tab-parsed" style="display:none"><pre id="preview-parsed" style="background:var(--bg);padding:1rem;border-radius:var(--radius);max-height:500px;overflow:auto;font-size:.78rem"></pre></div>
<div id="tab-raw" style="display:none"><textarea id="preview-raw" readonly style="width:100%;height:500px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;font-size:.75rem;font-family:monospace"></textarea></div>
</div>
</div>
<script>
var _d = __INIT__ || {};
var site = _d.site || {};
var results = _d.results || [];
var siteId = site.id || location.pathname.split('/').pop();
function renderDetail(){
document.getElementById('site-info').innerHTML =
'<div class="card"><div class="flex-between"><div><h2 style="margin-bottom:.3rem">'+(site.name||'')+'</h2><span class="text-muted">'+(site.url||'')+'</span>'+
(site.slug?'<br><a href="/s/'+site.slug+'" target="_blank" style="color:var(--primary);font-size:.85rem">공개: /s/'+site.slug+'</a>':'')+
'</div><a href="/admin/sites" class="btn btn-outline btn-sm">&larr; 목록</a></div></div>';
document.getElementById('cron-expr').value=site.cron_schedule||'';
document.getElementById('sched-active').checked=site.schedule_active||false;
document.getElementById('results-tbody').innerHTML = results.map(function(r){
return '<tr><td>'+r.id+'</td><td>'+timeAgo(r.crawled_at)+'</td>'+
'<td><span class="badge badge-'+(r.status==='success'?'success':'danger')+'">'+r.status+'</span></td>'+
'<td>'+(r.item_count||0)+'개</td><td class="text-muted">'+(r.error_message||'-').substring(0,50)+'</td>'+
'<td><button class="btn btn-outline btn-sm" onclick="preview('+r.id+')">보기</button></td></tr>';
}).join('') || '<tr><td colspan="6" class="text-muted" style="text-align:center">크롤링 결과가 없습니다</td></tr>';
}
renderDetail();
function setCron(e){document.getElementById('cron-expr').value=e;}
async function saveSchedule(){
await api('PUT','/api/sites/'+siteId+'/schedule',{cron_schedule:document.getElementById('cron-expr').value,schedule_active:document.getElementById('sched-active').checked});
toast('스케줄 저장 완료');
}
async function crawlNow(){
toast('크롤링 시작...','warning');
try{var r=await api('POST','/api/sites/'+siteId+'/crawl');if(r.error)throw new Error(r.error);toast('크롤링 완료! '+(r.itemCount||0)+'개 항목');
var res=await api('GET','/api/sites/'+siteId+'/results');if(res&&res.length!==undefined)results=res;renderDetail();
}catch(e){toast('크롤링 실패: '+e.message,'error');}
}
async function preview(resultId){
var r=await api('GET','/api/results/'+resultId);
document.getElementById('preview-iframe').srcdoc=r.rendered_html||'<p>없음</p>';
document.getElementById('preview-parsed').textContent=JSON.stringify(r.parsed_data,null,2);
document.getElementById('preview-raw').value=r.raw_html||'';
document.getElementById('previewModal').classList.add('active');showTab('rendered');
}
function showTab(n){['rendered','parsed','raw'].forEach(function(t){document.getElementById('tab-'+t).style.display=t===n?'block':'none';});}
</script>
` }) %>