Files
homeclaw/web-ui/html/writer-app.html
T
kimandClaude Sonnet 5 145f4e698f feat: 작가앱에 작품 제목 수정 기능 추가
- 백엔드 PUT /api/writer/works/:id는 이미 title 갱신을 지원했으나 프론트에 버튼이 없었음
- 헤더에 "✏️ 제목 수정" 버튼 추가, 작품 선택 시에만 표시

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PppKcQmeTJDQCpfcgKietu
2026-09-15 12:00:17 +09:00

1298 lines
73 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title id="doc-title">✍️ 작가</title>
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
<link rel="stylesheet" href="../css/styles.css">
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
<style>
:root{--brand:#8b5cf6;}
html,body{height:100%;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
.hdr{display:flex;align-items:center;gap:10px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;flex-wrap:wrap}
.hdr h1{font-size:14px;font-weight:800;margin:0;flex-shrink:0}
.hdr-sp{flex:1}
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:4px 10px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}
.hdr-btn:hover{border-color:var(--brand);color:var(--brand)}
.genre-switch{display:flex;gap:4px;background:var(--panel-2);border-radius:8px;padding:3px}
.genre-btn{padding:5px 12px;font-size:12px;font-weight:700;border:none;border-radius:6px;background:none;color:var(--muted);cursor:pointer;font-family:inherit}
.genre-btn.active{background:var(--brand);color:#fff}
.work-sel{font-size:12px;padding:6px 8px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:6px;font-family:inherit;cursor:pointer;max-width:180px}
.work-meta{font-size:11px;color:var(--muted);flex-shrink:0}
.layout{flex:1;min-height:0;display:grid;grid-template-columns:190px 1fr 320px;overflow:hidden}
.layout.no-chat{grid-template-columns:190px 1fr}
.layout.no-chat .right-chat{display:none}
/* 왼쪽: 탭 네비 + 전문가 버튼 */
.left-sb{background:var(--panel);border-right:1px solid var(--line);display:flex;flex-direction:column;overflow-y:auto;padding:10px 8px;gap:4px}
.nav-btn{display:flex;align-items:center;gap:6px;padding:8px 10px;font-size:12px;font-weight:700;border:none;border-radius:8px;background:none;color:var(--muted);cursor:pointer;font-family:inherit;text-align:left}
.nav-btn.active{background:var(--brand);color:#fff}
.nav-btn:not(.active):hover{background:var(--panel-2);color:var(--text)}
.sb-divider{font-size:9px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;padding:10px 10px 2px}
.expert-btn{display:flex;align-items:center;gap:6px;padding:8px 10px;font-size:12px;font-weight:600;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);cursor:pointer;font-family:inherit;text-align:left}
.expert-btn:hover{border-color:var(--brand);color:var(--brand)}
.expert-btn.active{border-color:var(--brand);background:color-mix(in srgb,var(--brand) 12%,transparent);color:var(--brand)}
.expert-btn.process{border-style:dashed}
/* 가운데: 작업창 */
.center-work{overflow-y:auto;padding:16px;box-sizing:border-box;display:flex;flex-direction:column;gap:12px}
.empty{color:var(--muted);font-size:12px;text-align:center;padding:30px}
#draft{width:100%;box-sizing:border-box;flex:1;min-height:300px;resize:vertical;font-size:14px;line-height:1.7;padding:12px 14px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:10px;font-family:inherit}
/* 원고 / 요소 전환 탭 */
.wv-tabs{display:flex;gap:4px;flex-shrink:0}
.wv-btn{padding:6px 14px;font-size:12px;font-weight:700;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--muted);cursor:pointer;font-family:inherit}
.wv-btn.active{background:var(--brand);color:#fff;border-color:var(--brand)}
/* 요소 입력 폼 */
#sub-elements{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:14px}
.el-group{display:flex;flex-direction:column;gap:5px}
.el-group .el-head{display:flex;align-items:baseline;gap:8px}
.el-group label{font-size:12px;font-weight:800;color:var(--text)}
.el-group .el-hint{font-size:11px;color:var(--muted)}
.el-group .el-review{margin-left:auto;font-size:10px;font-weight:700;border:1px solid var(--line);border-radius:6px;padding:2px 8px;background:none;color:var(--muted);cursor:pointer;font-family:inherit}
.el-group .el-review:hover{border-color:var(--brand);color:var(--brand)}
.el-group textarea{width:100%;box-sizing:border-box;font-size:13px;line-height:1.6;padding:9px 11px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:9px;font-family:inherit;resize:vertical;min-height:70px}
.review-btn{align-self:flex-start;background:var(--brand);color:#fff;border:none;border-radius:9px;padding:9px 18px;font-size:13px;font-weight:800;cursor:pointer;font-family:inherit;margin-top:4px}
.review-btn:disabled{opacity:.5;cursor:default}
/* AI 원고 작성 바 */
.compose-bar{flex-shrink:0;display:flex;flex-direction:column;gap:6px;padding:10px;background:var(--panel);border:1px solid var(--line);border-radius:10px}
#compose-prompt{width:100%;box-sizing:border-box;font-size:12.5px;line-height:1.5;padding:8px 10px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;font-family:inherit;resize:vertical;min-height:44px}
.compose-actions{display:flex;gap:6px;flex-wrap:wrap}
.compose-btn{background:var(--brand);color:#fff;border:none;border-radius:8px;padding:6px 14px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit}
.compose-btn.ghost{background:none;border:1px solid var(--line);color:var(--muted)}
.compose-btn.ghost:hover{border-color:var(--brand);color:var(--brand)}
.compose-btn:disabled{opacity:.5;cursor:default}
#compose-result{flex-shrink:0;display:flex;flex-direction:column;gap:8px;padding:10px;background:color-mix(in srgb,var(--brand) 6%,transparent);border:1px solid var(--brand);border-radius:10px}
.compose-result-head{display:flex;justify-content:space-between;align-items:baseline;font-size:11px;font-weight:800;color:var(--brand)}
.compose-result-text{white-space:pre-wrap;font-size:13px;line-height:1.7;max-height:280px;overflow-y:auto;padding:8px 10px;background:var(--panel);border:1px solid var(--line);border-radius:8px}
/* 오른쪽: 모델 선택 + 채팅 */
.right-chat{background:var(--panel);border-left:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
.model-box{padding:10px;border-bottom:1px solid var(--line);flex-shrink:0}
.model-box label{font-size:9px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;display:block;margin-bottom:4px}
#model-sel{width:100%;box-sizing:border-box;font-size:12px;padding:6px 8px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:6px;font-family:inherit;cursor:pointer}
.chat-active-tag{font-size:10px;font-weight:800;color:#fff;background:var(--brand);border-radius:5px;padding:2px 7px;display:inline-block;margin-top:6px}
.chat-msgs{flex:1;min-height:0;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:10px}
.msg{font-size:12.5px;line-height:1.6;white-space:pre-wrap;padding:8px 10px;border-radius:8px;max-width:100%}
.msg.user{background:var(--panel-2);align-self:flex-end}
.msg.assistant{background:color-mix(in srgb,var(--brand) 8%,transparent);border:1px solid var(--line)}
.msg.pending{color:var(--muted);font-style:italic}
.chat-input-row{display:flex;gap:6px;padding:10px;border-top:1px solid var(--line);flex-shrink:0}
.chat-input-row textarea{flex:1;box-sizing:border-box;font-size:12.5px;padding:8px 10px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;font-family:inherit;resize:none;min-height:36px;max-height:100px}
.chat-send{background:var(--brand);color:#fff;border:none;border-radius:8px;padding:0 14px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit}
.chat-send:disabled{opacity:.5;cursor:default}
/* 노트 탭 */
.note-toolbar{display:flex;gap:6px;flex-wrap:wrap}
.note-card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px}
.note-card .nc-top{display:flex;align-items:center;gap:8px;margin-bottom:6px}
.note-badge{font-size:9px;font-weight:800;padding:2px 7px;border-radius:8px;background:var(--panel-2);color:var(--muted)}
.note-card b{flex:1;font-size:13px}
.note-card .nc-actions{display:flex;gap:4px}
.note-card textarea{width:100%;box-sizing:border-box;font-size:12px;padding:6px 8px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:6px;font-family:inherit;resize:vertical;min-height:50px}
.icon-x{background:none;border:none;color:var(--muted);cursor:pointer;font-size:12px}
/* 낭독 탭 */
.tts-controls{display:flex;gap:8px;align-items:center}
#tts-audio{width:100%}
/* 통계 탭 */
.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px}
.stat-tile{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px;text-align:center}
.stat-tile .v{font-size:22px;font-weight:800;color:var(--brand)}
.stat-tile .l{font-size:11px;color:var(--muted);margin-top:4px}
.hist-row{display:flex;justify-content:space-between;font-size:11px;color:var(--muted);padding:4px 0;border-bottom:1px solid var(--line)}
/* 자료조사 탭 */
.research-row{display:flex;gap:6px}
.research-row input{flex:1;font-size:13px;padding:8px 10px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;font-family:inherit}
.research-item{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 12px}
.research-item a{font-size:13px;font-weight:700;color:var(--brand);text-decoration:none}
.research-item p{font-size:12px;color:var(--muted);margin:4px 0 0}
/* 서재(E-Book) 탭 */
.provider-tabs{display:flex;gap:6px;flex-wrap:wrap}
.provider-tab{display:flex;align-items:center;gap:5px;padding:6px 12px;font-size:12px;font-weight:700;border:1.5px solid var(--line);border-radius:999px;background:var(--panel);color:var(--text);cursor:pointer;font-family:inherit}
.provider-tab.active{border-color:var(--brand);background:color-mix(in srgb,var(--brand) 12%,transparent);color:var(--brand)}
.provider-tab .pcountry{font-size:10px;color:var(--muted);font-weight:600}
.provider-tab.needs-key{opacity:.7}
.provider-tab .key-badge{font-size:9px;font-weight:800;background:#f59e0b;color:#fff;border-radius:5px;padding:1px 5px}
.ebook-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
.ebook-card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px;display:flex;flex-direction:column;gap:6px}
.ebook-card img{width:100%;aspect-ratio:2/3;object-fit:cover;border-radius:6px;background:var(--panel-2)}
.ebook-card .et{font-size:12px;font-weight:700;line-height:1.3}
.ebook-card .ea{font-size:11px;color:var(--muted)}
.ebook-card .eb-actions{display:flex;gap:4px;margin-top:auto}
.ebook-card .eb-actions a,.ebook-card .eb-actions button{flex:1;text-align:center;font-size:11px;padding:5px 0;border:1px solid var(--line);border-radius:6px;background:var(--panel-2);color:var(--text);text-decoration:none;cursor:pointer;font-family:inherit}
.ebook-card .eb-actions a:hover,.ebook-card .eb-actions button:hover{border-color:var(--brand);color:var(--brand)}
.ebook-page-viewport{flex:1;min-height:0;position:relative;overflow:hidden;background:var(--panel-2);border:1px solid var(--line);border-radius:10px}
.ebook-page-spread{position:absolute;display:flex;align-items:stretch;gap:0;background:#faf6ec;border-radius:4px;box-shadow:0 4px 24px rgba(0,0,0,.18),inset 0 0 40px rgba(0,0,0,.04)}
:root[data-theme="dark"] .ebook-page-spread{background:#242019;box-shadow:0 4px 24px rgba(0,0,0,.4),inset 0 0 40px rgba(0,0,0,.25)}
.ebook-page-viewport.spread .ebook-page-spread{gap:56px}
.ebook-page-viewport.spread .ebook-page-spread::after{content:'';position:absolute;top:0;bottom:0;left:50%;width:1px;background:rgba(0,0,0,.08);z-index:2;pointer-events:none}
:root[data-theme="dark"] .ebook-page-viewport.spread .ebook-page-spread::after{background:rgba(0,0,0,.35)}
.ebook-page-slot{box-sizing:border-box;padding:60px 40px 30px;color:#2b2417;font-size:14.5px;line-height:1.85;white-space:pre-wrap;overflow:hidden;flex:1 1 0;min-width:0;opacity:1;transition:opacity .2s}
:root[data-theme="dark"] .ebook-page-slot{color:#dcd2b8}
.ebook-page-slot.hidden{display:none}
.ebook-page-slot.fading{opacity:0}
.ebook-turn-zone{position:absolute;top:0;bottom:0;width:14%;min-width:36px;display:flex;align-items:center;justify-content:center;font-size:22px;color:var(--muted);opacity:.55;cursor:pointer;transition:opacity .15s;z-index:3;user-select:none}
.ebook-turn-zone.left{left:0;background:linear-gradient(90deg,rgba(0,0,0,.06),transparent)}
.ebook-turn-zone.right{right:0;background:linear-gradient(270deg,rgba(0,0,0,.06),transparent)}
.ebook-turn-zone:hover{opacity:1;color:var(--brand)}
.ebook-turn-zone.disabled{opacity:.12!important;pointer-events:none}
.ebook-page-num{position:absolute;bottom:8px;left:0;right:0;text-align:center;font-size:10px;color:rgba(0,0,0,.35);z-index:2;pointer-events:none}
:root[data-theme="dark"] .ebook-page-num{color:rgba(255,255,255,.3)}
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%) translateY(20px);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 18px;font-size:12px;opacity:0;pointer-events:none;transition:.2s;z-index:999}
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
.toast.err{border-color:#ef4444;color:#ef4444}
</style>
</head>
<body>
<div class="hdr">
<a href="/" style="text-decoration:none;font-size:13px;color:var(--muted)">← 홈</a>
<h1>✍️ 작가</h1>
<div class="genre-switch" id="genre-switch"></div>
<select class="work-sel" id="work-sel" onchange="onWorkSelect()">
<option value="">작품을 선택하세요...</option>
</select>
<button class="hdr-btn" onclick="createWork()">+ 새 작품</button>
<button class="hdr-btn" onclick="renameCurrentWork()" id="work-rename-btn" style="display:none">✏️ 제목 수정</button>
<button class="hdr-btn" onclick="deleteCurrentWork()" id="work-del-btn" style="display:none">삭제</button>
<span class="work-meta" id="work-meta"></span>
<div class="hdr-sp"></div>
<button class="hdr-btn" onclick="toggleTheme()">🌙</button>
</div>
<div class="layout" id="layout">
<!-- 왼쪽: 탭 네비 + 전문가 버튼 -->
<div class="left-sb">
<button class="nav-btn active" id="nav-write" onclick="setTab('write')">✍️ 작가</button>
<button class="nav-btn" id="nav-notes" onclick="setTab('notes')">🗂️ 캐릭터·세계관</button>
<button class="nav-btn" id="nav-read" onclick="setTab('read')">🔊 낭독</button>
<button class="nav-btn" id="nav-stats" onclick="setTab('stats')">📊 작품 통계</button>
<button class="nav-btn" id="nav-research" onclick="setTab('research')">🔎 자료조사</button>
<button class="nav-btn" id="nav-ebooks" onclick="setTab('ebooks')">📚 서재</button>
<div id="expert-block">
<div class="sb-divider">요소</div>
<div id="expert-list-elements" style="display:flex;flex-direction:column;gap:4px"></div>
<button class="expert-btn" id="btn-review" onclick="startReview()" style="margin-top:4px;border-color:var(--brand);color:var(--brand);font-weight:800">📋 전체 검토</button>
<div class="sb-divider">프로세스</div>
<div id="expert-list-process" style="display:flex;flex-direction:column;gap:4px"></div>
</div>
</div>
<!-- 가운데: 작업창 -->
<div class="center-work" id="center-work">
<!-- 작가 탭 -->
<div id="pane-write" style="display:flex;flex-direction:column;flex:1;gap:10px">
<div class="wv-tabs">
<button class="wv-btn active" id="wv-draft" onclick="setWriteView('draft')">✍️ 원고</button>
<button class="wv-btn" id="wv-elements" onclick="setWriteView('elements')">🧩 요소</button>
</div>
<div id="sub-draft" style="display:flex;flex-direction:column;flex:1;gap:8px">
<textarea id="draft" placeholder="여기에 원고를 쓰세요. 자동 저장됩니다." oninput="onDraftInput()"></textarea>
<div class="compose-bar">
<textarea id="compose-prompt" rows="2" placeholder="AI에게 원고를 부탁하기 — 예: 이 설정으로 도입부 한 장면 써줘 / 여기서 이어서 써줘 / 선택한 문단을 더 긴장감 있게 다시 써줘" onkeydown="if(event.key==='Enter'&&(event.ctrlKey||event.metaKey)){event.preventDefault();compose('auto');}"></textarea>
<div class="compose-actions">
<button class="compose-btn" id="compose-continue" onclick="compose('continue')">✍️ 이어쓰기</button>
<button class="compose-btn" id="compose-scene" onclick="compose('scene')">🎬 새 장면</button>
<button class="compose-btn ghost" id="compose-rewrite" onclick="compose('rewrite')">🔁 선택부분 다시쓰기</button>
</div>
<div id="compose-context-hint" class="el-hint"></div>
</div>
<div id="compose-result" style="display:none">
<div class="compose-result-head"><span>AI 초안</span><span id="compose-result-mode" class="el-hint"></span></div>
<div id="compose-result-text" class="compose-result-text"></div>
<div class="compose-actions">
<button class="compose-btn" id="compose-insert" onclick="insertCompose()">📥 원고에 넣기</button>
<button class="compose-btn ghost" onclick="compose(lastComposeMode)">🔄 다시</button>
<button class="compose-btn ghost" onclick="discardCompose()">✕ 버리기</button>
</div>
</div>
</div>
<div id="sub-elements" style="display:none">
<div class="el-group">
<div class="el-head"><label>주제</label><span class="el-hint">이 작품으로 무엇을 말하고 싶은가</span><button class="el-review" onclick="selectExpert('theme')">💬 이 요소 검토</button></div>
<textarea id="el-theme" oninput="onElementInput()" placeholder="핵심 메시지·문제의식·독자에게 남기고 싶은 것"></textarea>
</div>
<div class="el-group">
<div class="el-head"><label>문체</label><span class="el-hint">어떤 문장 톤·화법을 지향하는가</span><button class="el-review" onclick="selectExpert('style')">💬 이 요소 검토</button></div>
<textarea id="el-style" oninput="onElementInput()" placeholder="예: 건조하고 짧은 문장 / 만연체 / 구어체 1인칭 / 시적 이미지 중심"></textarea>
</div>
<div class="el-group">
<div class="el-head"><label>구성</label><span class="el-hint">전개 순서·구조 계획</span><button class="el-review" onclick="selectExpert('structure')">💬 이 요소 검토</button></div>
<textarea id="el-structure" oninput="onElementInput()" placeholder="예: 기승전결 / 액자식 / 시간 역순 / 3막 구조, 각 장의 역할"></textarea>
</div>
<div class="el-group">
<div class="el-head"><label>배경</label><span class="el-hint">시공간·세계관 설정</span><button class="el-review" onclick="selectExpert('setting')">💬 이 요소 검토</button></div>
<textarea id="el-setting" oninput="onElementInput()" placeholder="시대·장소·분위기, 세계의 규칙, 고증이 필요한 지점"></textarea>
</div>
<div class="el-group">
<div class="el-head"><label>인물</label><span class="el-hint">주요 인물의 동기·관계</span><button class="el-review" onclick="selectExpert('character')">💬 이 요소 검토</button></div>
<textarea id="el-character" oninput="onElementInput()" placeholder="주인공의 욕망·결핍·변화, 대립 인물, 관계 구도"></textarea>
</div>
<button class="review-btn" id="review-btn" onclick="startReview()">📋 요소 + 원고 전체 검토받기</button>
</div>
</div>
<!-- 캐릭터/세계관 노트 탭 -->
<div id="pane-notes" style="display:none;flex-direction:column;gap:12px">
<div class="note-toolbar">
<button class="hdr-btn" onclick="addNote('character')">+ 인물</button>
<button class="hdr-btn" onclick="addNote('setting')">+ 배경/설정</button>
<button class="hdr-btn" onclick="addNote('timeline')">+ 타임라인</button>
<button class="hdr-btn" onclick="addNote('term')">+ 용어</button>
</div>
<div id="notes-list"></div>
</div>
<!-- 낭독 탭 -->
<div id="pane-read" style="display:none;flex-direction:column;gap:12px">
<div class="tts-controls">
<button class="hdr-btn" onclick="readAloud()" id="tts-btn">🔊 원고 읽기</button>
<span class="work-meta" id="tts-status"></span>
</div>
<audio id="tts-audio" controls style="display:none"></audio>
</div>
<!-- 작품 통계 탭 -->
<div id="pane-stats" style="display:none;flex-direction:column;gap:12px">
<div class="stat-grid" id="stat-grid"></div>
<div id="hist-list"></div>
</div>
<!-- 자료조사 탭 -->
<div id="pane-research" style="display:none;flex-direction:column;gap:12px">
<div class="research-row">
<input id="research-q" placeholder="고증·전문지식이 필요한 걸 검색해보세요" onkeydown="if(event.key==='Enter')doResearch()">
<button class="hdr-btn" onclick="doResearch()">검색</button>
</div>
<div id="research-list"></div>
</div>
<!-- 서재(E-Book) 탭 -->
<div id="pane-ebooks" style="display:none;flex-direction:column;flex:1;min-height:0;gap:12px">
<div style="display:flex;gap:6px">
<button class="nav-btn active" id="ebook-view-search" onclick="setEbookView('search')" style="flex:none;padding:6px 14px">🔎 검색</button>
<button class="nav-btn" id="ebook-view-library" onclick="setEbookView('library')" style="flex:none;padding:6px 14px">📖 내 서재</button>
</div>
<div id="ebook-search-view" style="display:flex;flex-direction:column;gap:12px">
<div class="provider-tabs" id="provider-tabs"></div>
<div class="research-row">
<input id="ebook-q" placeholder="책 제목·저자로 검색" onkeydown="if(event.key==='Enter')doEbookSearch()">
<button class="hdr-btn" onclick="doEbookSearch()">검색</button>
</div>
<div id="ebook-attribution" class="work-meta"></div>
<div class="ebook-grid" id="ebook-results"></div>
</div>
<div id="ebook-library-view" style="display:none;flex-direction:column;gap:12px">
<div class="ebook-grid" id="ebook-library-list"></div>
</div>
<div id="ebook-reader" style="display:none;flex-direction:column;gap:8px;flex:1;min-height:0">
<div style="display:flex;align-items:center;gap:8px">
<button class="hdr-btn" onclick="closeEbookReader()">← 목록으로</button>
<b id="ebook-reader-title" style="font-size:13px"></b>
<button class="hdr-btn" onclick="readEbookAloud()" id="ebook-tts-btn">🔊 낭독</button>
<label class="work-meta" style="display:flex;align-items:center;gap:4px">속도
<input type="range" id="ebook-tts-speed" min="0.7" max="1.5" step="0.05" value="0.9" style="width:90px" oninput="document.getElementById('ebook-tts-speed-label').textContent=Number(this.value).toFixed(2)+'x'">
<span id="ebook-tts-speed-label">0.90x</span>
</label>
<span class="work-meta" id="ebook-tts-status"></span>
<div class="hdr-sp"></div>
<span class="work-meta" id="ebook-page-label" style="font-weight:700"></span>
<label class="work-meta" style="display:flex;align-items:center;gap:4px">위치
<input type="range" id="ebook-jump-range" min="0" max="100" value="0" style="width:100px" oninput="document.getElementById('ebook-jump-label').textContent=this.value+'%'" onchange="jumpEbookTo(this.value)">
<span id="ebook-jump-label">0%</span>
</label>
</div>
<div style="font-size:10px;color:var(--muted)">Project Gutenberg 라이선스 안내문과 목차는 자동으로 건너뜁니다(책 형식에 따라 완벽하지 않을 수 있음) · 화면 가장자리 클릭 또는 ←/→ 키로 페이지를 넘길 수 있습니다</div>
<audio id="ebook-tts-audio" controls style="display:none"></audio>
<div class="ebook-page-viewport" id="ebook-page-viewport">
<div class="ebook-page-spread" id="ebook-page-spread">
<div class="ebook-page-slot" id="ebook-page-left"></div>
<div class="ebook-page-slot" id="ebook-page-right"></div>
</div>
<div class="ebook-turn-zone left" id="ebook-prev-zone" onclick="ebookPrevPage()">‹</div>
<div class="ebook-turn-zone right" id="ebook-next-zone" onclick="ebookNextPage()">›</div>
<div class="ebook-page-num" id="ebook-loading-note"></div>
</div>
</div>
</div>
</div>
<!-- 오른쪽: 모델 선택 + 채팅 -->
<div class="right-chat" id="right-chat">
<div class="model-box">
<label>모델</label>
<select id="model-sel"><option value="">기본 모델</option></select>
<div id="chat-active-tag"></div>
</div>
<div class="chat-msgs" id="chat-msgs">
<div class="empty">왼쪽에서 전문가를 선택하면 여기서 대화가 시작됩니다.</div>
</div>
<div class="chat-input-row">
<textarea id="chat-input" placeholder="전문가에게 물어보세요..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendChat();}"></textarea>
<button class="chat-send" id="chat-send-btn" onclick="sendChat()">전송</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const TOKEN_KEY='smallclaw_token';
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Authorization']='Bearer '+t;return h;}
function escHtml(s){return String(s ?? '').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}}
function showToast(msg,isErr){const t=document.getElementById('toast');t.textContent=msg;t.className='toast show'+(isErr?' err':'');setTimeout(()=>t.classList.remove('show'),2500);}
const GENRES=[
{code:'poetry',label:'시'},
{code:'novel',label:'소설'},
{code:'essay',label:'수필'},
{code:'scenario',label:'시나리오'},
];
const EXPERTS_ELEMENT=[
{code:'theme',label:'주제'},
{code:'style',label:'문체'},
{code:'structure',label:'구성'},
{code:'setting',label:'배경'},
{code:'character',label:'인물'},
];
const EXPERTS_PROCESS=[
{code:'idea',label:'발상'},
{code:'edit',label:'퇴고'},
];
const ALL_EXPERTS=[...EXPERTS_ELEMENT,...EXPERTS_PROCESS];
const NOTE_TYPE_LABEL={character:'인물',setting:'배경/설정',timeline:'타임라인',term:'용어'};
let WORKS=[];
let CURRENT=null;
let saveTimer=null;
let currentTab='write';
let activeExpert=null;
let chatHistory=[]; // [{role,content}]
let chatBusy=false;
let chatGen=0; // bumped on every expert switch / send — stale responses check against it
let chatAbort=null; // AbortController for the in-flight expert request
function renderGenreSwitch(){
const el=document.getElementById('genre-switch');
el.innerHTML=GENRES.map(g=>`<button class="genre-btn" data-g="${g.code}" onclick="setGenre('${g.code}')">${g.label}</button>`).join('');
}
function setGenre(code){
if(!CURRENT) return;
CURRENT.genre=code;
document.querySelectorAll('.genre-btn').forEach(b=>b.classList.toggle('active',b.dataset.g===code));
saveWork();
}
function renderExpertButtons(){
// 요소 버튼은 이제 "그 요소 입력칸으로 이동" — 즉시 AI를 부르지 않는다.
document.getElementById('expert-list-elements').innerHTML=EXPERTS_ELEMENT.map(e=>
`<button class="expert-btn" id="exp-${e.code}" onclick="openElement('${e.code}')">${e.label}</button>`).join('');
document.getElementById('expert-list-process').innerHTML=EXPERTS_PROCESS.map(e=>
`<button class="expert-btn process" id="exp-${e.code}" onclick="selectExpert('${e.code}')">${e.label}</button>`).join('');
}
const ELEMENT_CODES=['theme','style','structure','setting','character'];
function readElements(){
const o={};
for(const k of ELEMENT_CODES) o[k]=(document.getElementById('el-'+k)?.value||'');
return o;
}
const ELEMENT_LABELS={theme:'주제',style:'문체',structure:'구성',setting:'배경',character:'인물'};
function writeElements(el){
el=el||{};
for(const k of ELEMENT_CODES){ const ta=document.getElementById('el-'+k); if(ta) ta.value=el[k]||''; }
updateComposeHint();
}
function updateComposeHint(){
const hint=document.getElementById('compose-context-hint'); if(!hint) return;
const filled=ELEMENT_CODES.filter(k=>(document.getElementById('el-'+k)?.value||'').trim()).map(k=>ELEMENT_LABELS[k]);
hint.textContent=filled.length
? `반영 중인 요소: ${filled.join(', ')} — AI가 이 설정을 지켜 씁니다`
: `🧩 요소 탭에서 주제·문체·배경 등을 채우면 AI가 그 설정에 맞춰 씁니다`;
}
let writeView='draft';
function setWriteView(v){
writeView=(v==='elements')?'elements':'draft';
document.getElementById('sub-draft').style.display=(writeView==='draft')?'flex':'none';
document.getElementById('sub-elements').style.display=(writeView==='elements')?'flex':'none';
document.getElementById('wv-draft').classList.toggle('active',writeView==='draft');
document.getElementById('wv-elements').classList.toggle('active',writeView==='elements');
}
// ── AI 원고 작성 ──
let composeBusy=false;
let lastComposeText='';
let lastComposeMode='continue';
function setComposeBusy(b){
composeBusy=b;
for(const id of ['compose-continue','compose-scene','compose-rewrite','compose-insert']){
const el=document.getElementById(id); if(el) el.disabled=b;
}
}
async function compose(mode){
if(!CURRENT){ showToast('먼저 작품을 선택/생성하세요',true); return; }
if(composeBusy) return;
const draftEl=document.getElementById('draft');
const instruction=document.getElementById('compose-prompt').value.trim();
if(mode==='auto') mode=instruction?'scene':'continue';
let selection='';
if(mode==='rewrite'){
selection=draftEl.value.slice(draftEl.selectionStart,draftEl.selectionEnd).trim();
if(!selection){ showToast('다시 쓸 부분을 원고에서 먼저 선택하세요',true); return; }
}
if(mode==='scene' && !instruction){ showToast('어떤 장면을 쓸지 위 칸에 적어주세요',true); return; }
lastComposeMode=mode;
setComposeBusy(true);
const res=document.getElementById('compose-result');
const resText=document.getElementById('compose-result-text');
res.style.display='flex';
document.getElementById('compose-result-mode').textContent={continue:'이어쓰기',scene:'새 장면',rewrite:'선택부분 다시쓰기'}[mode]||'';
resText.textContent='쓰는 중…';
try{
await saveWork();
const model=document.getElementById('model-sel').value;
const r=await fetch('/api/writer/compose',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({
workId:CURRENT.id, content:draftEl.value, elements:readElements(),
instruction, mode, selection, model,
})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'생성 실패');
lastComposeText=String(d.text||'').trim();
resText.textContent=lastComposeText||'(빈 응답)';
}catch(e){
res.style.display='none';
showToast('오류: '+e.message,true);
}finally{ setComposeBusy(false); }
}
function insertCompose(){
if(!lastComposeText) return;
const draftEl=document.getElementById('draft');
if(lastComposeMode==='rewrite'){
const a=draftEl.selectionStart, b=draftEl.selectionEnd;
draftEl.value=draftEl.value.slice(0,a)+lastComposeText+draftEl.value.slice(b);
}else{
draftEl.value=draftEl.value.trimEnd()+(draftEl.value.trim()?'\n\n':'')+lastComposeText+'\n';
draftEl.scrollTop=draftEl.scrollHeight;
}
discardCompose();
onDraftInput();
showToast('원고에 추가했습니다');
}
function discardCompose(){
lastComposeText='';
document.getElementById('compose-result').style.display='none';
document.getElementById('compose-result-text').textContent='';
}
function openElement(code){
if(!CURRENT){ showToast('먼저 작품을 선택/생성하세요',true); return; }
if(currentTab!=='write') setTab('write');
setWriteView('elements');
document.querySelectorAll('.expert-btn').forEach(b=>b.classList.toggle('active',b.id==='exp-'+code));
const ta=document.getElementById('el-'+code);
if(ta){ ta.scrollIntoView({block:'center',behavior:'smooth'}); ta.focus(); }
}
function onElementInput(){
updateComposeHint();
if(saveTimer) clearTimeout(saveTimer);
saveTimer=setTimeout(saveWork,1200);
}
function startReview(){
if(!CURRENT){ showToast('먼저 작품을 선택/생성하세요',true); return; }
selectExpert('review');
}
function setTab(t){
currentTab=t;
['write','notes','read','stats','research','ebooks'].forEach(k=>{
document.getElementById('nav-'+k).classList.toggle('active',k===t);
document.getElementById('pane-'+k).style.display=(k===t)?'flex':'none';
});
document.getElementById('expert-block').style.display=(t==='write')?'':'none';
document.getElementById('layout').classList.toggle('no-chat',t!=='write');
if(t==='notes') loadNotes();
if(t==='stats') renderStats();
if(t==='ebooks' && !EBOOK_PROVIDERS.length) loadEbookProviders();
}
// ── 모델 목록 ──
async function loadModels(){
try{
const r=await fetch('/api/ollama/models',{headers:authH()});
const d=await r.json();
const sel=document.getElementById('model-sel');
if(d.success && Array.isArray(d.models) && d.models.length){
sel.innerHTML='<option value="">기본 모델</option>'+
d.models.map(m=>`<option value="${escHtml(m.name)}">${escHtml(m.name)}${m.vision?' 👁️':''}</option>`).join('');
}
}catch(e){ /* 모델 목록 못 가져와도 기본 모델로 계속 진행 가능 */ }
}
// ── 작품 ──
async function loadWorks(){
try{
const r=await fetch('/api/writer/works',{headers:authH()});
const d=await r.json();
if(!d.success) throw new Error(d.error||'목록 조회 실패');
WORKS=d.works||[];
const sel=document.getElementById('work-sel');
sel.innerHTML='<option value="">작품을 선택하세요...</option>'+
WORKS.map(w=>`<option value="${w.id}">${escHtml(w.title)} (${escHtml((GENRES.find(g=>g.code===w.genre)||{}).label||w.genre)})</option>`).join('');
let lastId=null; try{lastId=localStorage.getItem('writer_last_work');}catch{}
if(lastId && WORKS.some(w=>w.id===lastId)){ sel.value=lastId; await onWorkSelect(); }
else if(WORKS.length){ sel.value=WORKS[0].id; await onWorkSelect(); }
}catch(e){ showToast('오류: '+e.message,true); }
}
async function createWork(){
const title=prompt('작품 제목');
if(!title||!title.trim()) return;
try{
const r=await fetch('/api/writer/works',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({title,genre:'novel'})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'생성 실패');
await loadWorks();
document.getElementById('work-sel').value=d.work.id;
await onWorkSelect();
showToast('작품이 생성되었습니다');
}catch(e){ showToast('오류: '+e.message,true); }
}
async function renameCurrentWork(){
if(!CURRENT) return;
const title=prompt('새 작품 제목',CURRENT.title);
if(!title||!title.trim()||title.trim()===CURRENT.title) return;
try{
const r=await fetch('/api/writer/works/'+CURRENT.id,{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({title})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'수정 실패');
CURRENT.title=title.trim();
await loadWorks();
document.getElementById('work-sel').value=CURRENT.id;
showToast('제목이 변경되었습니다');
}catch(e){ showToast('오류: '+e.message,true); }
}
async function deleteCurrentWork(){
if(!CURRENT) return;
if(!confirm(`"${CURRENT.title}"을(를) 삭제할까요? 딸린 노트도 함께 삭제됩니다.`)) return;
try{
await fetch('/api/writer/works/'+CURRENT.id,{method:'DELETE',headers:authH()});
CURRENT=null;
try{localStorage.removeItem('writer_last_work');}catch{}
await loadWorks();
if(!WORKS.length){ document.getElementById('draft').value=''; document.getElementById('work-meta').textContent=''; }
showToast('삭제됨');
}catch(e){ showToast('오류: '+e.message,true); }
}
async function onWorkSelect(){
const id=document.getElementById('work-sel').value;
resetChat();
discardCompose();
document.getElementById('compose-prompt').value='';
if(!id){ CURRENT=null; document.getElementById('draft').value=''; writeElements({}); setWriteView('draft'); document.getElementById('work-del-btn').style.display='none'; document.getElementById('work-rename-btn').style.display='none'; return; }
try{
const r=await fetch('/api/writer/works/'+id,{headers:authH()});
const d=await r.json();
if(!d.success) throw new Error(d.error||'조회 실패');
CURRENT=d.work;
try{localStorage.setItem('writer_last_work',id);}catch{}
document.getElementById('draft').value=CURRENT.content||'';
writeElements(CURRENT.elements||{});
setWriteView('draft');
document.querySelectorAll('.genre-btn').forEach(b=>b.classList.toggle('active',b.dataset.g===CURRENT.genre));
document.getElementById('work-del-btn').style.display='';
document.getElementById('work-rename-btn').style.display='';
updateWorkMeta();
if(currentTab==='notes') loadNotes();
if(currentTab==='stats') renderStats();
}catch(e){ showToast('오류: '+e.message,true); }
}
function updateWorkMeta(){
if(!CURRENT) return;
const chars=(document.getElementById('draft').value||'').length;
document.getElementById('work-meta').textContent=`${chars.toLocaleString()}자`;
}
function onDraftInput(){
updateWorkMeta();
if(saveTimer) clearTimeout(saveTimer);
saveTimer=setTimeout(saveWork,1200);
}
async function saveWork(){
if(!CURRENT) return;
const content=document.getElementById('draft').value;
const elements=readElements();
try{
const r=await fetch('/api/writer/works/'+CURRENT.id,{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({content,elements,genre:CURRENT.genre,title:CURRENT.title})});
const d=await r.json();
if(d.success){ CURRENT=d.work; }
}catch(e){ /* 조용히 실패 — 다음 입력에서 재시도됨 */ }
}
// ── 전문가 채팅 ──
function resetChat(){
activeExpert=null;
chatHistory=[];
document.querySelectorAll('.expert-btn').forEach(b=>b.classList.remove('active'));
document.getElementById('chat-active-tag').innerHTML='';
document.getElementById('chat-msgs').innerHTML='<div class="empty">왼쪽에서 전문가를 선택하면 여기서 대화가 시작됩니다.</div>';
}
function selectExpert(code){
if(!CURRENT){ showToast('먼저 작품을 선택/생성하세요',true); return; }
// 이전 전문가 요청이 진행 중이면 취소 — 마지막에 고른 전문가가 이긴다.
// (안 그러면 앞선 요청 결과가 새로 고른 전문가 대화창에 들어가 "가로채는" 것처럼 보임)
if(chatAbort) chatAbort.abort();
chatGen++;
chatBusy=false;
document.getElementById('chat-send-btn').disabled=false;
activeExpert=code;
chatHistory=[];
document.querySelectorAll('.expert-btn').forEach(b=>b.classList.toggle('active',b.id==='exp-'+code||(code==='review'&&b.id==='btn-review')));
const label=code==='review'?'전체 검토':((ALL_EXPERTS.find(e=>e.code===code)||{}).label||code);
document.getElementById('chat-active-tag').innerHTML=`<span class="chat-active-tag">${code==='review'?label:label+' 전문가와 대화중'}</span>`;
document.getElementById('chat-msgs').innerHTML='';
sendChat(true); // 첫 피드백 자동 요청
}
function renderChat(){
const el=document.getElementById('chat-msgs');
el.innerHTML=chatHistory.map(m=>`<div class="msg ${m.role}">${escHtml(m.content)}</div>`).join('');
el.scrollTop=el.scrollHeight;
}
async function sendChat(isFirst){
if(!activeExpert) return;
const input=document.getElementById('chat-input');
const message=isFirst?'':input.value.trim();
if(!isFirst && !message) return;
// 진행 중이던 요청 취소 후 새 세대 시작 — 응답이 돌아왔을 때 이 세대가 아직 유효한지로 판별
if(chatAbort) chatAbort.abort();
chatAbort=new AbortController();
const myGen=++chatGen;
const reqExpert=activeExpert;
const reqSignal=chatAbort.signal;
if(!isFirst){ chatHistory.push({role:'user',content:message}); input.value=''; }
renderChat();
chatBusy=true;
document.getElementById('chat-send-btn').disabled=true;
const el=document.getElementById('chat-msgs');
el.insertAdjacentHTML('beforeend','<div class="msg assistant pending" id="pending-msg">생각하는 중...</div>');
el.scrollTop=el.scrollHeight;
try{
await saveWork();
const model=document.getElementById('model-sel').value;
const r=await fetch('/api/writer/expert',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({
expertType:reqExpert, workId:CURRENT.id, content:document.getElementById('draft').value,
elements:readElements(), message, history:chatHistory.slice(0,-1), model,
}),signal:reqSignal});
const d=await r.json();
if(myGen!==chatGen) return; // 그 사이 다른 전문가를 골랐거나 새 메시지를 보냄 — 이 응답은 버린다
if(!d.success) throw new Error(d.error||'요청 실패');
chatHistory.push({role:'assistant',content:d.feedback});
renderChat();
}catch(e){
if(reqSignal.aborted || myGen!==chatGen) return; // 취소·구세대 응답은 조용히 무시
document.getElementById('pending-msg')?.remove();
showToast('오류: '+e.message,true);
if(!isFirst) chatHistory.pop();
renderChat();
}finally{
if(myGen===chatGen){ // 최신 요청만 busy 상태를 해제 (구세대가 늦게 끝나도 UI 안 건드림)
chatBusy=false;
document.getElementById('chat-send-btn').disabled=false;
chatAbort=null;
}
}
}
// ── 캐릭터/세계관 노트 ──
async function loadNotes(){
const el=document.getElementById('notes-list');
if(!CURRENT){ el.innerHTML='<div class="empty">먼저 작품을 선택하세요.</div>'; return; }
el.innerHTML='<div class="empty">불러오는 중...</div>';
try{
const r=await fetch('/api/writer/notes/'+CURRENT.id,{headers:authH()});
const d=await r.json();
if(!d.success) throw new Error(d.error||'조회 실패');
if(!d.notes.length){ el.innerHTML='<div class="empty">아직 노트가 없습니다. 위 버튼으로 추가해보세요.</div>'; return; }
el.innerHTML=d.notes.map(n=>`
<div class="note-card" data-id="${n.id}" style="margin-bottom:10px">
<div class="nc-top">
<span class="note-badge">${NOTE_TYPE_LABEL[n.type]||n.type}</span>
<b>${escHtml(n.name)}</b>
<div class="nc-actions">
<button class="icon-x" onclick="saveNoteEdit('${n.id}')" title="저장">💾</button>
<button class="icon-x" onclick="deleteNote('${n.id}')" title="삭제">🗑️</button>
</div>
</div>
<textarea id="note-content-${n.id}">${escHtml(n.content)}</textarea>
</div>`).join('');
}catch(e){ el.innerHTML='<div class="empty">오류: '+escHtml(e.message)+'</div>'; }
}
async function addNote(type){
if(!CURRENT){ showToast('먼저 작품을 선택하세요',true); return; }
const name=prompt((NOTE_TYPE_LABEL[type]||type)+' 이름');
if(!name||!name.trim()) return;
try{
const r=await fetch('/api/writer/notes/'+CURRENT.id,{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({type,name,content:''})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'추가 실패');
loadNotes();
}catch(e){ showToast('오류: '+e.message,true); }
}
async function saveNoteEdit(noteId){
const content=document.getElementById('note-content-'+noteId).value;
try{
const r=await fetch('/api/writer/notes/'+CURRENT.id+'/'+noteId,{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({content})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'저장 실패');
showToast('저장됨');
}catch(e){ showToast('오류: '+e.message,true); }
}
async function deleteNote(noteId){
if(!confirm('이 노트를 삭제할까요?')) return;
try{
await fetch('/api/writer/notes/'+CURRENT.id+'/'+noteId,{method:'DELETE',headers:authH()});
loadNotes();
}catch(e){ showToast('오류: '+e.message,true); }
}
// ── 낭독 ──
async function readAloud(){
if(!CURRENT){ showToast('먼저 작품을 선택하세요',true); return; }
const text=document.getElementById('draft').value.trim();
if(!text){ showToast('원고 내용이 없습니다',true); return; }
const btn=document.getElementById('tts-btn');
btn.disabled=true;
document.getElementById('tts-status').textContent='음성 합성 중...';
try{
const r=await fetch('/api/voice/tts',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({text:text.slice(0,4000)})});
if(!r.ok){ const d=await r.json().catch(()=>({})); throw new Error(d.error||'TTS 실패'); }
const blob=await r.blob();
const url=URL.createObjectURL(blob);
const audio=document.getElementById('tts-audio');
audio.src=url;
audio.style.display='block';
audio.play().catch(()=>{});
document.getElementById('tts-status').textContent=text.length>4000?'앞 4000자만 재생됩니다':'';
}catch(e){
document.getElementById('tts-status').textContent='';
showToast('오류: '+e.message,true);
}finally{ btn.disabled=false; }
}
// ── 작품 통계 ──
function renderStats(){
const grid=document.getElementById('stat-grid');
const histEl=document.getElementById('hist-list');
if(!CURRENT){ grid.innerHTML=''; histEl.innerHTML='<div class="empty">먼저 작품을 선택하세요.</div>'; return; }
const text=document.getElementById('draft').value||'';
const chars=text.length;
const charsNoSpace=text.replace(/\s/g,'').length;
const dialogueMatches=text.match(/["'“‘][^"'”’]*["'”’]/g)||[];
const dialogueChars=dialogueMatches.reduce((s,m)=>s+m.length,0);
const dialoguePct=chars?Math.round(dialogueChars/chars*100):0;
grid.innerHTML=`
<div class="stat-tile"><div class="v">${chars.toLocaleString()}</div><div class="l">전체 글자수</div></div>
<div class="stat-tile"><div class="v">${charsNoSpace.toLocaleString()}</div><div class="l">공백 제외</div></div>
<div class="stat-tile"><div class="v">${dialoguePct}%</div><div class="l">대사 비중(추정)</div></div>
<div class="stat-tile"><div class="v">${(CURRENT.history||[]).length}</div><div class="l">저장 스냅샷 수</div></div>
`;
const hist=(CURRENT.history||[]).slice().reverse();
if(!hist.length){ histEl.innerHTML='<div class="empty">아직 집필 추이 기록이 없습니다 — 저장할 때마다 하루 단위로 쌓입니다.</div>'; return; }
histEl.innerHTML='<div style="font-size:11px;color:var(--muted);font-weight:700;margin-bottom:4px">집필 추이</div>'+
hist.map(h=>`<div class="hist-row"><span>${new Date(h.t).toLocaleDateString('ko-KR')}</span><span>${h.chars.toLocaleString()}자</span></div>`).join('');
}
// ── 자료조사 ──
async function doResearch(){
const q=document.getElementById('research-q').value.trim();
if(!q) return;
const el=document.getElementById('research-list');
el.innerHTML='<div class="empty">검색 중...</div>';
try{
const r=await fetch('/api/writer/research',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({query:q})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'검색 실패');
if(!d.results.length){ el.innerHTML='<div class="empty">검색 결과가 없습니다.</div>'; return; }
el.innerHTML=d.results.map(it=>`
<div class="research-item">
<a href="${escHtml(it.url)}" target="_blank" rel="noopener">${escHtml(it.title||it.url)}</a>
<p>${escHtml((it.snippet||'').slice(0,200))}</p>
</div>`).join('');
}catch(e){ el.innerHTML='<div class="empty">오류: '+escHtml(e.message)+'</div>'; }
}
// ── 서재 (E-Book: Gutenberg/Google Books/알라딘) ──
let ebookReaderState=null; // {url, offset, title}
let EBOOK_PROVIDERS=[];
let activeProvider='gutenberg';
async function loadEbookProviders(){
try{
const r=await fetch('/api/writer/ebooks/providers',{headers:authH()});
const d=await r.json();
if(!d.success) throw new Error(d.error||'조회 실패');
EBOOK_PROVIDERS=d.providers;
renderProviderTabs();
}catch(e){ /* 탭 안 열어봤으면 조용히 무시 */ }
}
function renderProviderTabs(){
const el=document.getElementById('provider-tabs');
el.innerHTML=EBOOK_PROVIDERS.map(p=>`
<button class="provider-tab${p.id===activeProvider?' active':''}${p.needsKey&&!p.configured?' needs-key':''}" onclick="selectProvider('${p.id}')">
${escHtml(p.name)} <span class="pcountry">${escHtml(p.country)}</span>
${p.needsKey&&!p.configured?'<span class="key-badge">키 필요</span>':''}
</button>`).join('');
}
async function selectProvider(id){
const p=EBOOK_PROVIDERS.find(x=>x.id===id);
if(!p) return;
if(p.needsKey && !p.configured){
const key=prompt(`${p.name} API 키를 입력하세요(한 번만 입력하면 서버에 안전하게 저장됩니다):`);
if(!key||!key.trim()) return;
try{
const r=await fetch('/api/writer/ebooks/providers/'+id+'/key',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({apiKey:key.trim()})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'저장 실패');
showToast(p.name+' 키가 저장되었습니다');
await loadEbookProviders();
}catch(e){ showToast('오류: '+e.message,true); return; }
}
activeProvider=id;
renderProviderTabs();
document.getElementById('ebook-results').innerHTML='';
document.getElementById('ebook-attribution').textContent='';
}
async function doEbookSearch(){
const q=document.getElementById('ebook-q').value.trim();
if(!q) return;
closeEbookReader();
const el=document.getElementById('ebook-results');
el.innerHTML='<div class="empty">검색 중...</div>';
document.getElementById('ebook-attribution').textContent='';
try{
const r=await fetch(`/api/writer/ebooks/search?provider=${activeProvider}&q=`+encodeURIComponent(q),{headers:authH()});
const d=await r.json();
if(d.needsKey){ el.innerHTML='<div class="empty">이 공급자는 API 키 설정이 필요합니다 — 위 탭을 눌러 키를 입력해주세요.</div>'; return; }
if(!d.success) throw new Error(d.error||'검색 실패');
if(d.attribution) document.getElementById('ebook-attribution').textContent=d.attribution;
if(!d.books.length){ el.innerHTML='<div class="empty">검색 결과가 없습니다.</div>'; return; }
el.innerHTML=d.books.map(b=>`
<div class="ebook-card">
${b.cover?`<img src="${escHtml(b.cover)}" alt="" loading="lazy">`:'<div style="aspect-ratio:2/3;background:var(--panel-2);border-radius:6px"></div>'}
<div class="et">${escHtml(b.title)}</div>
<div class="ea">${escHtml(b.authors||'저자 미상')}</div>
<div class="eb-actions">
${b.textUrl?`<button onclick='openEbookReader(${JSON.stringify(b.textUrl)},${JSON.stringify(b.title)})'>읽기</button>`:''}
${b.epubUrl?`<button onclick='downloadEbookToServer(${JSON.stringify(b.epubUrl)},${JSON.stringify(b.title)},${JSON.stringify(b.authors||'')},${JSON.stringify(activeProvider)},${JSON.stringify(b.textUrl||'')},this)'>다운로드</button>`:''}
${!b.epubUrl&&!b.textUrl&&b.link?`<a href="${escHtml(b.link)}" target="_blank" rel="noopener">보러가기</a>`:''}
</div>
</div>`).join('');
}catch(e){ el.innerHTML='<div class="empty">오류: '+escHtml(e.message)+'</div>'; }
}
// ── 서재 뷰 전환(검색/내 서재) + 서버사이드 다운로드 보관 ──
function setEbookView(v){
document.getElementById('ebook-view-search').classList.toggle('active',v==='search');
document.getElementById('ebook-view-library').classList.toggle('active',v==='library');
document.getElementById('ebook-search-view').style.display=v==='search'?'flex':'none';
document.getElementById('ebook-library-view').style.display=v==='library'?'flex':'none';
if(v==='library') loadEbookLibrary();
}
async function downloadEbookToServer(url,title,authors,source,readUrl,btn){
if(btn){ btn.disabled=true; btn.textContent='저장 중...'; }
try{
const r=await fetch('/api/writer/ebooks/download',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({url,title,authors,source,readUrl})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'다운로드 실패');
showToast('내 서재에 저장되었습니다');
if(btn){ btn.textContent='저장됨 ✓'; }
}catch(e){
showToast('오류: '+e.message,true);
if(btn){ btn.disabled=false; btn.textContent='다운로드'; }
}
}
async function loadEbookLibrary(){
const el=document.getElementById('ebook-library-list');
el.innerHTML='<div class="empty">불러오는 중...</div>';
try{
const r=await fetch('/api/writer/ebooks/library',{headers:authH()});
const d=await r.json();
if(!d.success) throw new Error(d.error||'조회 실패');
if(!d.books.length){ el.innerHTML='<div class="empty">아직 저장한 책이 없습니다 — 검색 탭에서 다운로드해보세요.</div>'; return; }
el.innerHTML=d.books.map(b=>`
<div class="ebook-card">
<div class="et">${escHtml(b.title)}</div>
<div class="ea">${escHtml(b.authors||'저자 미상')}</div>
<div class="ea">${(b.sizeBytes/1024).toFixed(0)}KB · ${new Date(b.downloadedAt).toLocaleDateString('ko-KR')}${b.bookmarkOffset?' · 책갈피 있음':''}</div>
<div class="eb-actions">
${b.readUrl?`<button onclick='openEbookReader(${JSON.stringify(b.readUrl)},${JSON.stringify(b.title)},${JSON.stringify(b.id)},${b.bookmarkOffset||0})'>${b.bookmarkOffset?'이어읽기':'읽기'}</button>`:''}
<a href="/api/writer/ebooks/library/${b.id}/file">받기</a>
<button onclick="deleteEbookFromLibrary('${b.id}')">삭제</button>
</div>
</div>`).join('');
}catch(e){ el.innerHTML='<div class="empty">오류: '+escHtml(e.message)+'</div>'; }
}
async function deleteEbookFromLibrary(id){
if(!confirm('이 책을 서재에서 삭제할까요?')) return;
try{
await fetch('/api/writer/ebooks/library/'+id,{method:'DELETE',headers:authH()});
loadEbookLibrary();
}catch(e){ showToast('오류: '+e.message,true); }
}
// 페이지 넘김 리더 상태.
// 처음엔 caretRangeFromPoint로 각 페이지에 정확히 들어갈 글자 구간을 실측했으나, 크로미움
// 멀티컬럼 클리핑 버그·뷰포트 밖 좌표 히트테스트 실패·문단 경계에서 창을 자르며 생기는
// 자투리 페이지 등 엣지케이스가 계속 나왔다. 그래서 실측을 포기하고, 페이지 크기(가로/세로
// 픽셀)에서 평균 글자폭·줄높이로 대략 몇 글자가 들어갈지 계산해 그 글자수로 그냥 자른다.
// 책마다, 페이지마다 하단 여백이 균일하지 않을 수 있지만(글자수 기준일 뿐 실제 줄바꿈은
// 안 재보므로) 버그 표면적이 훨씬 작다 — 사용자 요청으로 단순한 방식을 택함(2026-07-22).
let ebookPager={pageW:0,spread:1,charsPerPage:1500,pageOffsets:[0],pageIdx:0,fetching:false};
async function openEbookReader(url,title,libraryId,bookmarkOffset){
const returnView=document.getElementById('ebook-view-library').classList.contains('active')?'library':'search';
ebookReaderState={url,title,fullText:'',totalLength:0,nextOffset:null,returnView,libraryId:libraryId||null,baseOffset:0};
ebookPager={pageW:0,spread:1,charsPerPage:1500,pageOffsets:[0],pageIdx:0,fetching:false};
document.getElementById('ebook-search-view').style.display='none';
document.getElementById('ebook-library-view').style.display='none';
document.getElementById('ebook-reader').style.display='flex';
document.getElementById('ebook-reader-title').textContent=title;
document.getElementById('ebook-page-left').textContent='불러오는 중...';
document.getElementById('ebook-page-right').textContent='';
await loadMoreEbookText(true,bookmarkOffset>0?`offset=${bookmarkOffset}`:undefined);
}
// 책갈피: 서재에서 연 책이면(libraryId 있음) 페이지가 바뀔 때마다 절대 오프셋(baseOffset+
// 현재 페이지의 청크 내 오프셋)을 저장해뒀다가, 다음에 "이어읽기"로 그 자리부터 바로 연다.
let ebookBookmarkTimer=null;
function currentAbsoluteEbookOffset(){
if(!ebookReaderState) return 0;
return (ebookReaderState.baseOffset||0)+(ebookPager.pageOffsets[ebookPager.pageIdx]||0);
}
function saveEbookBookmarkNow(){
if(!ebookReaderState||!ebookReaderState.libraryId) return;
const offset=currentAbsoluteEbookOffset();
fetch(`/api/writer/ebooks/library/${ebookReaderState.libraryId}/bookmark`,{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({offset})}).catch(()=>{});
}
function scheduleEbookBookmarkSave(){
if(!ebookReaderState||!ebookReaderState.libraryId) return;
clearTimeout(ebookBookmarkTimer);
ebookBookmarkTimer=setTimeout(saveEbookBookmarkNow,1200);
}
function closeEbookReader(){
if(ebookAutoReading) stopEbookAloud();
if(ebookBookmarkTimer){ clearTimeout(ebookBookmarkTimer); saveEbookBookmarkNow(); }
const returnView=ebookReaderState?.returnView||'search';
ebookReaderState=null;
document.getElementById('ebook-reader').style.display='none';
setEbookView(returnView);
}
async function loadMoreEbookText(replace,extraParams){
if(!ebookReaderState) return;
try{
const params=extraParams||`offset=${replace?0:ebookReaderState.nextOffset}`;
const r=await fetch(`/api/writer/ebooks/read?url=${encodeURIComponent(ebookReaderState.url)}&${params}`,{headers:authH()});
const d=await r.json();
if(!d.success) throw new Error(d.error||'읽기 실패');
if(replace){ ebookReaderState.fullText=d.text; ebookPager.pageIdx=0; ebookReaderState.baseOffset=d.offset||0; }
else{ ebookReaderState.fullText+=d.text; }
if(d.totalLength){
ebookReaderState.totalLength=d.totalLength;
const pct=Math.round((d.offset/d.totalLength)*100);
document.getElementById('ebook-jump-range').value=pct;
document.getElementById('ebook-jump-label').textContent=pct+'%';
}
ebookReaderState.nextOffset=d.nextOffset;
computeEbookLayout();
}catch(e){ showToast('오류: '+e.message,true); }
}
function jumpEbookTo(percent){
if(!ebookReaderState) return;
document.getElementById('ebook-page-left').textContent='이동 중...';
document.getElementById('ebook-page-right').textContent='';
loadMoreEbookText(true,`percent=${percent}`);
}
// 챕터/장/절 제목("CHAPTER I.", "PART ONE" 등, 줄 전체가 그 내용뿐인 경우)이 나오는
// 글자 위치를 찾는다 — 실제 책처럼 챕터가 항상 새 페이지에서 시작하게 하기 위함. 책마다
// 표기가 달라 완벽할 수 없는 휴리스틱이지만, 영어 Gutenberg 텍스트에서 가장 흔한 패턴이다.
function findChapterOffsets(text){
const offsets=[];
const re=/^[ \t]*(?:CHAPTER|LETTER|PART|BOOK|VOLUME)\s+[IVXLCDM0-9]+\.?[ \t]*$/gim;
let m;
while((m=re.exec(text))) offsets.push(m.index);
return offsets;
}
// 목표 글자수 지점에서 끊되, 우선순위는: ① 챕터 제목이 걸리면 그 앞에서(챕터는 항상 새
// 페이지에서 시작) ② 근처(최대 200자 앞쪽)에 문단 경계(빈 줄)가 있으면 거기서(문단 중간에
// 안 끊기도록) ③ 그것도 없으면 가장 가까운 공백에서(최소한 단어는 안 끊기도록 — 그냥 글자수
// 로 딱 자르면 "furlough"가 "furlo"/"ugh"로 잘려나가 보기 흉했다). DOM 측정 없는 순수
// 문자열 검색이라 가볍고, 크로미움 렌더링 버그와도 무관하다.
// hasMore(서버에 이어지는 텍스트가 더 있음)일 때는 "지금 로드된 텍스트의 끝"이 "책의 끝"이
// 아니므로, 그 경계에서도 문단/단어 스냅을 그대로 적용한다 — 안 그러면 서버 청크(2만자)
// 경계가 하필 단어 중간이면("forcin|g") 스냅 없이 그대로 반토막 난 채 페이지가 끝나버린다
// (2026-07-23, 프랑켄슈타인 실독 중 발견). 스냅 지점을 못 찾으면(로드된 부분에 공백이 없을
// 만큼 촉박한 경우) 아예 이 페이지를 만들지 않고 루프를 멈춘다 — 다음 청크가 로드되면
// 이 함수가 더 큰 버퍼로 다시 호출되어 정상적으로 계산된다.
// charsPerPage는 "한 줄을 cols자만큼 채운 프로즈"를 가정해 어림한 예산이라, 책 제목면/
// 편지 인사말처럼 짧은 줄+빈 줄이 잔뜩인 구간에서는 실제 시각적 줄 수가 예산보다 훨씬
// 커져(짧은 줄도 세로 공간은 한 줄 그대로 차지) 페이지 하단이 넘쳐 잘린다(2026-07-23,
// 프랑켄슈타인 1페이지 실측: 글자수 예산상 19줄 자리인데 실제로는 34줄 필요했음).
// 그래서 글자수 예산과는 별도로 "실제 줄 수" 상한도 계산해서 더 짧은 쪽을 쓴다.
// 문장 끝(.!? + 선택적 닫는 인용부호/괄호) 바로 뒤 공백인지 판정 — 문단 경계가 없을 때
// 아무 단어 사이에서나 끊지 않고 문장 단위로 끊기게 하기 위함(2026-07-23 사용자 요청).
function isSentenceBoundary(text,i){
if(!/\s/.test(text[i-1])) return false;
let j=i-2;
while(j>=0 && /["'\)\]]/.test(text[j])) j--;
return j>=0 && /[.!?]/.test(text[j]);
}
function visualLineCutoff(text,pos,limit,cols){
let i=pos, used=0;
while(i<text.length && used<limit){
let nl=text.indexOf('\n',i);
if(nl===-1) nl=text.length;
const len=nl-i;
const vis=Math.max(1,Math.ceil((len||1)/cols));
if(used+vis>limit){
const remain=limit-used;
return Math.min(text.length,i+remain*cols);
}
used+=vis;
i=nl+1;
}
return i;
}
function computePageOffsets(text,charsPerPage,chapterOffsets,hasMore,linesPerPage,cols){
const offsets=[0];
let pos=0;
while(pos<text.length){
let next=Math.min(text.length,pos+charsPerPage);
if(linesPerPage&&cols){
const lineCap=visualLineCutoff(text,pos,linesPerPage,cols);
if(lineCap<next) next=lineCap;
}
const forced=chapterOffsets.find(c=>c>pos+20 && c<next);
if(forced!=null){
next=forced;
}else if(next<text.length || hasMore){
const paraBack=Math.max(pos+Math.floor(charsPerPage*0.5),next-200);
const paraIdx=text.lastIndexOf('\n\n',next-1);
if(paraIdx>=paraBack && paraIdx>pos){
next=paraIdx+2;
}else{
let sentSnap=-1;
for(let i=next;i>paraBack;i--){ if(isSentenceBoundary(text,i)){ sentSnap=i; break; } }
if(sentSnap>pos){
next=sentSnap;
}else if(next>=text.length || (!/\s/.test(text[next-1]) && !/\s/.test(text[next]))){
let snap=-1;
const back=Math.max(pos+1,next-40);
for(let i=next;i>back;i--){ if(/\s/.test(text[i-1])){ snap=i; break; } }
if(snap===-1 && next<text.length){
const fwd=Math.min(text.length,next+40);
for(let i=next;i<fwd;i++){ if(/\s/.test(text[i])){ snap=i+1; break; } }
}
if(snap>pos){ next=snap; }
else if(next>=text.length && hasMore){ break; }
}
}
}
if(next<=pos) break;
offsets.push(next);
pos=next;
}
return offsets;
}
// 페이지 "내용"(페이지당 글자수)은 창 크기와 무관하게 고정된 기준 치수로만 계산한다 —
// 그래야 브라우저 창 크기를 바꿔도 페이지 구분이 흔들리지 않는다(2026-07-22 사용자 요청).
// 화면에는 이 고정 크기 그대로 그린 뒤 CSS transform:scale로 뷰포트에 맞춰 확대/축소만
// 한다 — 고정 레이아웃 PDF/이북처럼 내용은 안 변하고 화면에 맞춰 배율만 바뀌는 방식.
// 페이지 크기(가로폭)는 페이지당 글자수를 좌우한다 — 원래 값(380/420)은 페이지당 630~
// 785자(약 100~130단어)로, 일반 페이퍼백 소설(페이지당 약 250~300단어, 1500~1800자)보다
// 훨씬 적었다. 폰트/줄간격은 그대로 두고 페이지 자체를 키워 페이지당 약 1650~1800자가
// 되도록 조정(2026-07-23, 사용자 요청).
const REF_ASPECT=0.66, REF_SPREAD_PAGE_W=600, REF_SPREAD_GAP=56, REF_NARROW_PAGE_W=580;
function computeEbookLayout(){
const viewport=document.getElementById('ebook-page-viewport');
const spreadEl=document.getElementById('ebook-page-spread');
if(!viewport||!spreadEl||!ebookReaderState) return;
const vw=viewport.clientWidth, vh=viewport.clientHeight;
if(vw<10||vh<10) return;
const padX=40, padTop=60, padBottom=30;
const narrow=vw<760;
const spread=narrow?1:2;
const gap=narrow?0:REF_SPREAD_GAP;
const pageW=narrow?REF_NARROW_PAGE_W:REF_SPREAD_PAGE_W;
const bookH=Math.floor(pageW/REF_ASPECT);
const bookW=spread===2?pageW*2+gap:pageW;
const scale=Math.min(vw/bookW,vh/bookH);
spreadEl.style.width=bookW+'px';
spreadEl.style.height=bookH+'px';
spreadEl.style.left='0';
spreadEl.style.top='0';
spreadEl.style.transformOrigin='top left';
spreadEl.style.transform=`translate(${Math.round((vw-bookW*scale)/2)}px, ${Math.round((vh-bookH*scale)/2)}px) scale(${scale})`;
const pageInnerH=bookH-padTop-padBottom, pageInnerW=pageW-padX*2;
const LINE_H=26.8, AVG_CHAR_W=7.6, FILL=0.85; // 넘치지 않게 여유를 두고 어림(overflow:hidden이 안전망)
const lines=Math.max(1,Math.floor(pageInnerH/LINE_H));
const cols=Math.max(1,Math.floor(pageInnerW/AVG_CHAR_W));
const charsPerPage=Math.max(200,Math.floor(lines*cols*FILL));
// 페이지당 글자수 자체는 이제 고정이라 리사이즈로 안 바뀌지만(위 주석 참고), 좁은/넓은
// 화면 전환(spread 모드 변경) 시에는 바뀔 수 있어 읽던 위치(글자 오프셋)는 유지한다.
const charOffset=(ebookPager.pageOffsets&&ebookPager.pageOffsets[ebookPager.pageIdx])||0;
const chapterOffsets=findChapterOffsets(ebookReaderState.fullText);
const hasMore=ebookReaderState.nextOffset!=null;
const pageOffsets=computePageOffsets(ebookReaderState.fullText,charsPerPage,chapterOffsets,hasMore,lines,cols);
let pageIdx=0;
while(pageIdx<pageOffsets.length-1 && pageOffsets[pageIdx+1]<=charOffset) pageIdx++;
ebookPager={...ebookPager,pageW,spread,charsPerPage,pageOffsets,pageIdx};
viewport.classList.toggle('spread',spread===2);
document.getElementById('ebook-page-right').classList.toggle('hidden',spread===1);
renderLeaves();
}
function renderLeaves(){
const p=ebookPager;
const fullText=ebookReaderState.fullText;
const totalPages=p.pageOffsets.length-1;
const maxPageIdx=Math.max(0,p.spread===2?Math.floor((totalPages-1)/2)*2:totalPages-1);
p.pageIdx=Math.min(Math.max(0,p.pageIdx),maxPageIdx);
const setSlot=(el,idx)=>{
const text=idx<totalPages?fullText.slice(p.pageOffsets[idx],p.pageOffsets[idx+1]):'';
if(el.textContent===text) return;
el.classList.add('fading');
requestAnimationFrame(()=>{
el.textContent=text;
requestAnimationFrame(()=>el.classList.remove('fading'));
});
};
setSlot(document.getElementById('ebook-page-left'),p.pageIdx);
if(p.spread===2) setSlot(document.getElementById('ebook-page-right'),p.pageIdx+1);
const hasMoreServer=ebookReaderState.nextOffset!=null;
const lastPageShown=p.pageIdx+p.spread-1;
const atStart=p.pageIdx<=0;
const atEnd=lastPageShown>=totalPages-1 && !hasMoreServer;
document.getElementById('ebook-prev-zone').classList.toggle('disabled',atStart);
document.getElementById('ebook-next-zone').classList.toggle('disabled',atEnd);
const from=p.pageIdx+1;
const to=Math.min(totalPages,from+p.spread-1);
const label=document.getElementById('ebook-page-label');
if(ebookReaderState.totalLength){
const estTotal=Math.max(totalPages,Math.round(ebookReaderState.totalLength/p.charsPerPage));
label.textContent=(from===to?`${from}p`:`${from}-${to}p`)+(hasMoreServer?` / 약${estTotal}p`:` / ${estTotal}p`);
}else{
label.textContent=(from===to?`${from}p`:`${from}-${to}p`);
}
// 로드된 텍스트의 마지막 스프레드 근처면 다음 서버 청크를 미리 당겨온다
if(!p.fetching && hasMoreServer && lastPageShown>=totalPages-2){
p.fetching=true;
loadMoreEbookText(false).finally(()=>{ ebookPager.fetching=false; });
}
scheduleEbookBookmarkSave();
}
async function ebookNextPage(){
const p=ebookPager;
const totalPages=p.pageOffsets.length-1;
const maxPageIdx=Math.max(0,p.spread===2?Math.floor((totalPages-1)/2)*2:totalPages-1);
if(p.pageIdx<maxPageIdx){ p.pageIdx+=p.spread; renderLeaves(); return; }
if(!ebookReaderState||p.fetching||ebookReaderState.nextOffset==null) return;
p.fetching=true;
document.getElementById('ebook-loading-note').textContent='다음 페이지 불러오는 중...';
await loadMoreEbookText(false);
p.fetching=false;
document.getElementById('ebook-loading-note').textContent='';
const newTotalPages=ebookPager.pageOffsets.length-1;
if(ebookPager.pageIdx+ebookPager.spread<newTotalPages){ ebookPager.pageIdx+=ebookPager.spread; renderLeaves(); }
}
function ebookPrevPage(){
const p=ebookPager;
if(p.pageIdx>0){ p.pageIdx=Math.max(0,p.pageIdx-p.spread); renderLeaves(); }
}
window.addEventListener('resize',()=>{ if(ebookReaderState) computeEbookLayout(); });
document.addEventListener('keydown',(e)=>{
if(!ebookReaderState) return;
if(document.getElementById('ebook-reader').style.display==='none') return;
const tag=(document.activeElement&&document.activeElement.tagName)||'';
if(tag==='INPUT'||tag==='TEXTAREA') return;
if(e.key==='ArrowRight'){ ebookNextPage(); }
else if(e.key==='ArrowLeft'){ ebookPrevPage(); }
});
// DOM textContent는 renderLeaves()의 requestAnimationFrame 안에서 갱신돼 페이지를 막
// 넘긴 직후엔 아직 이전 페이지 텍스트가 남아있을 수 있다 — 연속 낭독 중 다음 페이지로
// 넘어가자마자 바로 읽으면 헌 페이지를 읽어버리는 걸 피하려고, DOM이 아니라
// ebookPager 상태(pageOffsets/fullText)에서 직접 현재 페이지 텍스트를 계산한다.
function getCurrentEbookPageText(){
const p=ebookPager, fullText=ebookReaderState.fullText;
const totalPages=p.pageOffsets.length-1;
const left=p.pageIdx<totalPages?fullText.slice(p.pageOffsets[p.pageIdx],p.pageOffsets[p.pageIdx+1]):'';
const right=(p.spread===2&&p.pageIdx+1<totalPages)?fullText.slice(p.pageOffsets[p.pageIdx+1],p.pageOffsets[p.pageIdx+2]):'';
return (left+' '+right).trim();
}
let ebookAutoReading=false;
function stopEbookAloud(){
ebookAutoReading=false;
const audio=document.getElementById('ebook-tts-audio');
audio.onended=null;
audio.pause();
const btn=document.getElementById('ebook-tts-btn');
btn.textContent='🔊 낭독';
btn.disabled=false;
document.getElementById('ebook-tts-status').textContent='';
}
async function readEbookAloud(){
if(ebookAutoReading){ stopEbookAloud(); return; }
ebookAutoReading=true;
document.getElementById('ebook-tts-btn').textContent='⏸ 정지';
await readCurrentEbookPageAloud();
}
async function readCurrentEbookPageAloud(){
const text=getCurrentEbookPageText();
if(!text){ if(ebookAutoReading) stopEbookAloud(); else showToast('읽을 내용이 없습니다',true); return; }
document.getElementById('ebook-tts-status').textContent='음성 합성 중...';
try{
const speed=Number(document.getElementById('ebook-tts-speed').value)||1;
const r=await fetch('/api/writer/ebooks/tts',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({text:text.slice(0,3000),speed})});
if(!r.ok){ const d=await r.json().catch(()=>({})); throw new Error(d.error||'낭독 실패'); }
const blob=await r.blob();
const url=URL.createObjectURL(blob);
const audio=document.getElementById('ebook-tts-audio');
audio.src=url;
audio.style.display='block';
// 연속 낭독 모드면 오디오가 끝났을 때 자동으로 다음 페이지로 넘기고 이어서 읽는다.
audio.onended=()=>{ if(ebookAutoReading) ebookAdvanceAndContinueReading(); };
audio.play().catch(()=>{});
document.getElementById('ebook-tts-status').textContent=text.length>3000?'현재 화면의 앞 3000자만 재생됩니다':'';
}catch(e){
document.getElementById('ebook-tts-status').textContent='';
showToast('오류: '+e.message,true);
stopEbookAloud();
}
}
async function ebookAdvanceAndContinueReading(){
const p=ebookPager;
const totalPages=p.pageOffsets.length-1;
const maxPageIdx=Math.max(0,p.spread===2?Math.floor((totalPages-1)/2)*2:totalPages-1);
if(p.pageIdx<maxPageIdx){
p.pageIdx+=p.spread;
renderLeaves();
}else if(ebookReaderState&&ebookReaderState.nextOffset!=null){
await loadMoreEbookText(false);
}else{
stopEbookAloud();
return;
}
if(ebookAutoReading) await readCurrentEbookPageAloud();
}
// ── INIT ──
renderGenreSwitch();
renderExpertButtons();
loadModels();
loadWorks();
</script>
</body>
</html>