brunch-diary / static /index.html
openfree's picture
feat: ginigen/Today news source + 1h interval [static/index.html]
be9c2b7 verified
Raw
History Blame Contribute Delete
16.7 kB
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>비드래프트 AI 다이어리</title>
<style>
:root {
--bg: #0f0f11;
--card: #18181c;
--border: #2a2a32;
--text: #e8e8f0;
--muted: #8888a0;
--accent: #a78bfa;
--accent2: #60a5fa;
--warm: #f59e0b;
--serif: 'Georgia', 'Nanum Myeongjo', serif;
--sans: 'Inter', 'Noto Sans KR', sans-serif;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
min-height: 100vh;
}
/* ── Header ── */
header {
border-bottom: 1px solid var(--border);
padding: 20px 40px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
background: rgba(15,15,17,0.95);
backdrop-filter: blur(12px);
z-index: 100;
}
.logo {
font-family: var(--serif);
font-size: 20px;
color: var(--accent);
font-weight: normal;
}
.logo span { color: var(--muted); font-size:13px; margin-left:12px; }
.status-bar {
display: flex;
align-items: center;
gap: 16px;
font-size: 12px;
color: var(--muted);
}
.pulse {
width: 8px; height: 8px;
background: #4ade80;
border-radius: 50%;
animation: pulse 2s infinite;
}
.pulse.busy { background: var(--warm); }
@keyframes pulse {
0%,100% { opacity:1; transform:scale(1); }
50% { opacity:.5; transform:scale(1.3); }
}
.btn-gen {
background: var(--accent);
color: #fff;
border: none;
padding: 6px 14px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: opacity .2s;
}
.btn-gen:hover { opacity: .85; }
.btn-gen:disabled { opacity:.4; cursor:not-allowed; }
/* ── Layout ── */
.layout {
display: grid;
grid-template-columns: 380px 1fr;
min-height: calc(100vh - 65px);
}
/* ── Sidebar ── */
aside {
border-right: 1px solid var(--border);
overflow-y: auto;
max-height: calc(100vh - 65px);
position: sticky;
top: 65px;
}
.sidebar-head {
padding: 20px 24px 14px;
font-size: 11px;
color: var(--muted);
letter-spacing: .08em;
text-transform: uppercase;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.count-badge {
background: var(--border);
padding: 2px 8px;
border-radius: 20px;
font-size: 11px;
}
.article-card {
padding: 18px 24px;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background .15s;
position: relative;
}
.article-card:hover { background: rgba(167,139,250,.06); }
.article-card.active { background: rgba(167,139,250,.1); }
.article-card.active::before {
content:'';
position: absolute;
left:0; top:0; bottom:0;
width: 3px;
background: var(--accent);
}
.card-thumb {
width: 100%;
aspect-ratio: 16/7;
object-fit: cover;
border-radius: 8px;
margin-bottom: 12px;
background: var(--border);
}
.card-date {
font-size: 11px;
color: var(--muted);
margin-bottom: 6px;
font-family: monospace;
}
.card-title {
font-family: var(--serif);
font-size: 14.5px;
line-height: 1.5;
color: var(--text);
margin-bottom: 8px;
}
.card-preview {
font-size: 12px;
color: var(--muted);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ── Main content ── */
main {
overflow-y: auto;
max-height: calc(100vh - 65px);
padding: 48px 60px;
}
/* ── Welcome ── */
.welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
gap: 16px;
}
.welcome-icon { font-size: 48px; }
.welcome h2 {
font-family: var(--serif);
font-size: 24px;
font-weight: normal;
color: var(--text);
}
.welcome p { color: var(--muted); font-size:14px; line-height:1.7; max-width:420px; }
/* ── Article reader ── */
#article-reader { display: none; }
.reader-header { margin-bottom: 36px; }
.reader-date {
font-size: 12px;
color: var(--muted);
font-family: monospace;
margin-bottom: 12px;
}
.reader-title {
font-family: var(--serif);
font-size: 32px;
font-weight: normal;
line-height: 1.35;
color: var(--text);
margin-bottom: 24px;
}
/* ── Image grid ── */
.image-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin: 32px 0;
}
.image-grid img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 10px;
background: var(--border);
transition: transform .2s;
}
.image-grid img:first-child {
grid-column: span 2;
aspect-ratio: 16/7;
}
.image-grid img:hover { transform: scale(1.01); }
.img-caption {
font-size: 11px;
color: var(--muted);
text-align: center;
margin-top: -6px;
margin-bottom: 4px;
font-style: italic;
}
/* ── Article body ── */
.article-body {
font-family: var(--serif);
font-size: 17px;
line-height: 1.9;
color: var(--text);
max-width: 660px;
}
.article-body p {
margin-bottom: 1.4em;
}
.article-body h1, .article-body h2, .article-body h3 {
font-family: var(--serif);
font-weight: normal;
color: var(--text);
margin: 1.8em 0 .8em;
}
.article-body h1 { font-size: 26px; }
.article-body h2 { font-size: 20px; }
.article-body hr {
border: none;
border-top: 1px solid var(--border);
margin: 2em 0;
}
.article-body em { color: var(--muted); }
.article-body strong { color: var(--accent); font-weight:600; }
.article-body a { color: var(--accent2); text-decoration:none; }
/* ── Generating overlay ── */
.gen-notice {
background: rgba(167,139,250,.08);
border: 1px solid rgba(167,139,250,.2);
border-radius: 10px;
padding: 20px 24px;
margin-bottom: 32px;
display: none;
align-items: center;
gap: 14px;
font-size: 14px;
color: var(--accent);
}
.gen-notice.show { display: flex; }
.spinner {
width: 20px; height: 20px;
border: 2px solid rgba(167,139,250,.3);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Empty state ── */
.empty {
text-align:center;
padding: 60px 20px;
color: var(--muted);
}
.empty .icon { font-size: 40px; margin-bottom: 16px; }
/* ── Timer ── */
.timer-pill {
background: var(--card);
border: 1px solid var(--border);
border-radius: 20px;
padding: 4px 12px;
font-size: 11px;
color: var(--muted);
font-family: monospace;
}
/* ── Mobile ── */
@media (max-width: 768px) {
header { padding: 14px 20px; }
.layout { grid-template-columns: 1fr; }
aside {
max-height: 45vh;
position: static;
border-right: none;
border-bottom: 1px solid var(--border);
}
main { padding: 28px 20px; max-height: none; }
.image-grid { grid-template-columns: 1fr; }
.image-grid img:first-child { grid-column: span 1; aspect-ratio: 16/9; }
.reader-title { font-size: 24px; }
.article-body { font-size: 15px; }
}
</style>
</head>
<body>
<!-- ── Header ── -->
<header>
<div class="logo">
비드래프트 AI 다이어리
<span>매 시간 자동 생성 · ginigen/Today 뉴스</span>
</div>
<div class="status-bar">
<div class="pulse" id="pulse-dot"></div>
<span id="status-text">로딩 중…</span>
<span class="timer-pill" id="timer-pill">--:--:--</span>
<button class="btn-gen" id="btn-gen" onclick="triggerGenerate()">지금 생성</button>
</div>
</header>
<!-- ── Layout ── -->
<div class="layout">
<!-- Sidebar -->
<aside>
<div class="sidebar-head">
최근 글
<span class="count-badge" id="count-badge">0</span>
</div>
<div id="article-list">
<div class="empty"><div class="icon">📝</div>글이 없습니다<br>잠시 후 자동 생성됩니다</div>
</div>
</aside>
<!-- Main -->
<main>
<!-- 생성 중 알림 -->
<div class="gen-notice" id="gen-notice">
<div class="spinner"></div>
<div>
<strong>새 글 생성 중…</strong><br>
<span style="font-size:12px;color:var(--muted)">GitHub 메모리 → ginigen/Today 뉴스 → Claude 에세이 → fal.ai 이미지 4장</span>
</div>
</div>
<!-- 웰컴 (초기) -->
<div class="welcome" id="welcome">
<div class="welcome-icon">✍️</div>
<h2>AI와 함께 쓰는 오늘의 이야기</h2>
<p>GitHub 장기기억과 HuggingFace 최신 논문을 읽고, 브런치 스타일의 에세이와 이미지 4장을 자동으로 생성합니다.</p>
<p style="margin-top:8px;color:var(--accent);">← 왼쪽 목록의 글을 클릭해 읽어보세요.</p>
</div>
<!-- 아티클 리더 -->
<div id="article-reader">
<div class="reader-header">
<div class="reader-date" id="reader-date"></div>
<h1 class="reader-title" id="reader-title"></h1>
</div>
<div class="image-grid" id="image-grid"></div>
<div class="article-body" id="reader-body"></div>
</div>
</main>
</div>
<script>
// ── 상태 ──────────────────────────────────────────────────────────
let articles = [];
let activeId = null;
let nextGenTime = null;
let isGenerating = false;
let timerInterval = null;
// ── API 호출 ──────────────────────────────────────────────────────
async function fetchArticles() {
try {
const r = await fetch('/api/articles');
const d = await r.json();
articles = d.articles || [];
isGenerating = d.is_generating || false;
nextGenTime = d.next_gen_time ? new Date(d.next_gen_time) : null;
renderSidebar();
updateStatus();
} catch(e) {
console.error(e);
}
}
async function triggerGenerate() {
const btn = document.getElementById('btn-gen');
btn.disabled = true;
await fetch('/api/generate');
setTimeout(fetchArticles, 1000);
}
// ── 렌더링 ────────────────────────────────────────────────────────
function renderSidebar() {
const list = document.getElementById('article-list');
const badge = document.getElementById('count-badge');
badge.textContent = articles.length;
if (!articles.length) {
list.innerHTML = '<div class="empty"><div class="icon">📝</div>글이 없습니다<br>잠시 후 자동 생성됩니다</div>';
return;
}
list.innerHTML = articles.map(a => {
const thumb = thumbUrl(a, 0);
const dateStr = formatDate(a.ts);
return `
<div class="article-card${a.id === activeId ? ' active' : ''}"
onclick="openArticle('${a.id}')">
${thumb ? `<img class="card-thumb" src="${thumb}" alt="" loading="lazy">` : ''}
<div class="card-date">${dateStr}</div>
<div class="card-title">${escHtml(a.title)}</div>
<div class="card-preview">${escHtml(a.preview || '')}</div>
</div>`;
}).join('');
}
function openArticle(id) {
activeId = id;
const a = articles.find(x => x.id === id);
if (!a) return;
// Sidebar 활성화
document.querySelectorAll('.article-card').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.article-card').forEach(el => {
if (el.onclick.toString().includes(`'${id}'`)) el.classList.add('active');
});
// 웰컴 숨김
document.getElementById('welcome').style.display = 'none';
// 헤더
document.getElementById('reader-date').textContent = formatDate(a.ts);
document.getElementById('reader-title').textContent = a.title;
// 이미지
const grid = document.getElementById('image-grid');
if (a.images && a.images.length) {
grid.innerHTML = a.images.map((img, i) => `
<div>
<img src="/proxy/image/${a.id}/${i}" alt="${escHtml(img.prompt || '')}" loading="lazy"
onerror="this.style.display='none'">
<div class="img-caption">${escHtml(img.prompt || '')}</div>
</div>`).join('');
grid.style.display = '';
} else {
grid.innerHTML = '';
}
// 본문
document.getElementById('reader-body').innerHTML = markdownToHtml(a.full || '');
document.getElementById('article-reader').style.display = 'block';
// 스크롤 상단
document.querySelector('main').scrollTo(0, 0);
}
// ── 상태 표시 ─────────────────────────────────────────────────────
function updateStatus() {
const dot = document.getElementById('pulse-dot');
const txt = document.getElementById('status-text');
const btn = document.getElementById('btn-gen');
const notice = document.getElementById('gen-notice');
if (isGenerating) {
dot.className = 'pulse busy';
txt.textContent = '생성 중…';
btn.disabled = true;
notice.classList.add('show');
} else {
dot.className = 'pulse';
txt.textContent = `글 ${articles.length}개`;
btn.disabled = false;
notice.classList.remove('show');
}
}
function startTimer() {
if (timerInterval) clearInterval(timerInterval);
const pill = document.getElementById('timer-pill');
timerInterval = setInterval(() => {
if (!nextGenTime) { pill.textContent = '--:--:--'; return; }
const diff = Math.max(0, nextGenTime - Date.now());
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
pill.textContent = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}, 1000);
}
// ── 유틸 ─────────────────────────────────────────────────────────
function thumbUrl(a, idx) {
if (!a.images || !a.images.length) return '';
return `/proxy/image/${a.id}/${idx}`;
}
function formatDate(ts) {
if (!ts) return '';
const s = ts.replace('_', ' ');
return s.length > 10 ? s : s;
}
function escHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function markdownToHtml(md) {
if (!md) return '<p style="color:var(--muted)">내용이 없습니다.</p>';
// 제목(H1) 줄 제거 — reader-title에 이미 표시됨
const lines = md.split('\n');
const bodyLines = lines[0].startsWith('# ') ? lines.slice(1) : lines;
let text = bodyLines.join('\n').trim();
// 특수문자 이스케이프
text = text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
// 블록 요소 먼저 처리
text = text.replace(/^---+$/gm, '<hr>');
text = text.replace(/^## (.+)$/gm, '<h2>$1</h2>');
text = text.replace(/^### (.+)$/gm, '<h3>$1</h3>');
// 인라인 요소
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
text = text.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// 단락 분리 — 빈 줄 기준
const paragraphs = text.split(/\n\n+/);
const html = paragraphs.map(p => {
p = p.trim();
if (!p) return '';
if (p.startsWith('<h') || p.startsWith('<hr')) return p;
// 단락 내 단일 줄바꿈 → <br>
return '<p>' + p.replace(/\n/g, '<br>') + '</p>';
}).filter(Boolean).join('\n');
return html || '<p style="color:var(--muted)">내용을 불러오는 중…</p>';
}
// ── 초기화 ────────────────────────────────────────────────────────
(async function init() {
await fetchArticles();
startTimer();
// 30초마다 폴링 (생성 중이면 10초)
setInterval(async () => {
await fetchArticles();
if (!timerInterval) startTimer();
}, isGenerating ? 10000 : 30000);
})();
</script>
</body>
</html>