- 백엔드 PUT /api/writer/works/:id는 이미 title 갱신을 지원했으나 프론트에 버튼이 없었음 - 헤더에 "✏️ 제목 수정" 버튼 추가, 작품 선택 시에만 표시 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PppKcQmeTJDQCpfcgKietu
1298 lines
73 KiB
HTML
1298 lines
73 KiB
HTML
<!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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|