Spaces:
Running
Running
| <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,'&').replace(/</g,'<').replace(/>/g,'>'); | |
| } | |
| 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,'&').replace(/</g,'<').replace(/>/g,'>'); | |
| // 블록 요소 먼저 처리 | |
| 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> | |