This commit is contained in:
2026-08-12 17:20:03 +09:00
parent 1f696902da
commit b526fe4670
3 changed files with 533 additions and 66 deletions
@@ -60,7 +60,9 @@
transition: background-color 0.3s, border-color 0.3s;
}
.header-left, .header-right { display: flex; align-items: center; gap: 12px; }
.header-left { flex: 1; display: flex; align-items: center; gap: 12px; }
.header-center { flex: 2; text-align: center; font-weight: bold; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 0 10px; color: var(--text-color); }
.header-right { flex: 1; display: flex; align-items: center; justify-content: flex-end; gap: 12px; }
.icon-btn {
cursor: pointer;
@@ -244,6 +246,8 @@
<span class="icon-btn" id="btn-font-plus" title="글자 크게">A+</span>
<span class="icon-btn" id="btn-theme" title="테마 변경">☀️</span>
</div>
<div class="header-center" id="viewer-title">
</div>
<div class="header-right">
<span id="btn-return-library">서재로 돌아가기</span>
</div>
@@ -260,6 +264,8 @@
<button id="btn-backup" style="flex: 1; padding: 10px; background: #27ae60; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: bold;">📥 백업하기</button>
<button id="btn-restore" style="flex: 1; padding: 10px; background: #8e44ad; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: bold;">📤 복원하기</button>
</div>
<button id="btn-manage-keywords" style="margin-top: 10px; width: 100%; padding: 10px; background: #e67e22; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: bold;">🏷️ 태그 및 키워드 관리</button>
<input type="file" id="restore-input" accept=".json" style="display: none;">
</div>
</div>
@@ -302,6 +308,28 @@
<span id="page-info">0 / 0</span>
</div>
<div id="keyword-modal" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 200; align-items: center; justify-content: center;">
<div class="box" style="width: 90%; max-width: 500px; max-height: 80vh; display: flex; flex-direction: column; padding: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; border-bottom: 1px solid var(--border-color); padding-bottom: 10px;">
<h2 style="margin: 0; font-size: 17px;">🏷️ 태그 및 키워드 관리</h2>
<span id="keyword-close-btn" style="cursor: pointer; font-size: 22px; color: #888;">&times;</span>
</div>
<p style="font-size: 12px; color: #aaa; margin: 0 0 10px 0; text-align: left;">
태그명과 감지할 키워드(쉼표로 구분)를 설정하세요.
</p>
<div id="keyword-rules-list" style="overflow-y: auto; flex: 1; text-align: left; margin-bottom: 15px; padding-right: 5px;">
</div>
<div style="display: flex; gap: 8px;">
<button id="btn-add-keyword-rule" style="flex: 1; padding: 10px; background: #2980b9; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">+ 태그 추가</button>
<button id="btn-reset-keyword-rules" style="padding: 10px; background: #7f8c8d; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">초기화</button>
<button id="btn-save-keyword-rules" style="flex: 1; padding: 10px; background: #27ae60; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">저장하기</button>
</div>
</div>
</div>
<script>
// ----------------------------------------------------------------
// [0] 초기 변수 및 URL 파라미터 세팅
@@ -508,14 +536,21 @@
const savedDataA = JSON.parse(localStorage.getItem(pKeyA)) || {};
const savedDataB = JSON.parse(localStorage.getItem(pKeyB)) || {};
// lastReadTime(타임스탬프) 우선 비교 -> 없으면 lastRead(문자열 날짜) 파싱 -> 둘 다 없으면 0
// 1순위: 즐겨찾기 우선 정렬 (true인 것이 앞으로 오게)
const favA = savedDataA.isFavorite ? 1 : 0;
const favB = savedDataB.isFavorite ? 1 : 0;
if (favA !== favB) {
return favB - favA;
}
// 2순위: 즐겨찾기 여부가 같다면 기존처럼 최근 열람 시간순 정렬
const getTime = (data) => {
if (data.lastReadTime) return data.lastReadTime;
if (data.lastRead) return new Date(data.lastRead.replace(/-/g, '/')).getTime();
return 0;
};
return getTime(savedDataB) - getTime(savedDataA); // 내림차순 정렬 (최근 읽은 책이 맨 앞으로)
return getTime(savedDataB) - getTime(savedDataA);
});
libraryList.innerHTML = '';
@@ -523,32 +558,49 @@
libraryList.innerHTML = '<li style="padding: 20px; text-align: center; color: #888;">저장된 책이 없습니다.</li>';
} else {
allBooks.forEach(book => {
const li = document.createElement('li');
li.className = 'library-item';
const li = document.createElement('li');
li.className = 'library-item';
// 1. 로컬 스토리지에서 진행도, 시간, 메모 데이터 불러오기
const pKey = `webReader_progress_${book.id}`;
const savedData = JSON.parse(localStorage.getItem(pKey)) || {};
// 1. 로컬 스토리지 데이터 불러오기
const pKey = `webReader_progress_${book.id}`;
const savedData = JSON.parse(localStorage.getItem(pKey)) || {};
let progressText = savedData.chapter !== undefined
? `<div style="font-size:12px; color:#888; margin-left: 8px;">(읽는 중: Ch.${savedData.chapter + 1})</div>`
: '';
// ⭐ [추가] 총 챕터 수 및 총 글자 수 계산
const totalChapters = book.chapters ? book.chapters.length : 0;
const totalChars = book.chapters ? book.chapters.reduce((sum, chap) => sum + (chap.content ? chap.content.length : 0), 0) : 0;
const formattedChars = totalChars.toLocaleString(); // 천 단위 쉼표 추가 (예: 125,430자)
let lastReadText = savedData.lastRead
? `<div style="font-size:11px; color:#aaa; margin-top: 6px;">🕒 마지막 열람: ${savedData.lastRead}</div>`
: '';
let bookStatsText = `<div style="font-size:11px; color:#aaa; margin-top: 4px;">📚 총 ${totalChapters}화 · ${formattedChars}자</div>`;
let memoText = savedData.memo
? `<div style="font-size:12px; color:#f39c12; margin-top: 4px; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">📝 ${savedData.memo}</div>`
: '';
let progressText = savedData.chapter !== undefined
? `<div style="font-size:12px; color:#888; margin-left: 8px;">(읽는 중: Ch.${savedData.chapter + 1})</div>`
: '';
// 2. 책 제목, 진행도, 시간, 메모를 담는 텍스트 영역 구성
const titleArea = document.createElement('div');
titleArea.style.cssText = "overflow: hidden; flex: 1; padding-right: 10px;";
titleArea.innerHTML = `
let lastReadText = savedData.lastRead
? `<div style="font-size:11px; color:#aaa; margin-top: 6px;">🕒 마지막 열람: ${savedData.lastRead}</div>`
: '';
let memoText = savedData.memo
? `<div style="font-size:12px; color:#f39c12; margin-top: 4px; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">📝 ${savedData.memo}</div>`
: '';
// 태그 HTML 생성
let tagsText = '';
if (savedData.tags && savedData.tags.length > 0) {
const badges = savedData.tags.map(tag =>
`<span style="display:inline-block; padding: 3px 6px; margin-right: 5px; background: rgba(0, 168, 255, 0.2); color: var(--accent-color); border-radius: 4px; font-size: 11px; font-weight: bold;">${tag}</span>`
).join('');
tagsText = `<div style="margin-top: 6px;">${badges}</div>`;
}
// 2. 책 제목, 분량 정보, 태그, 진행도, 시간, 메모 조립
const titleArea = document.createElement('div');
titleArea.style.cssText = "overflow: hidden; flex: 1; padding-right: 10px;";
titleArea.innerHTML = `
<div style="text-overflow: ellipsis; white-space: nowrap; overflow:hidden;">
<strong>${book.fileName}</strong> ${progressText}
</div>
${bookStatsText} ${tagsText}
${lastReadText}
${memoText}
`;
@@ -557,6 +609,19 @@
const btnArea = document.createElement('div');
btnArea.style.cssText = "display: flex; gap: 8px; flex-shrink: 0;";
const isFav = savedData.isFavorite || false;
const favBtn = document.createElement('button');
favBtn.innerText = isFav ? "⭐ 해제" : "☆ 즐겨찾기";
// 즐겨찾기 활성화 시 배경색을 다르게 주어 눈에 띄게 설정
favBtn.style.cssText = `padding: 5px 10px; background-color: ${isFav ? '#f1c40f' : '#7f8c8d'}; color: white; border: none; border-radius: 4px; font-size: 12px; cursor: pointer;`;
favBtn.onclick = (e) => {
e.stopPropagation(); // 뷰어로 넘어가는 현상 차단
savedData.isFavorite = !isFav; // 상태 반전 (true <-> false)
localStorage.setItem(pKey, JSON.stringify(savedData));
location.reload(); // 변경사항(정렬 및 버튼 텍스트) 즉시 반영을 위해 새로고침
};
// 4. [메모] 버튼 생성 및 이벤트
const memoBtn = document.createElement('button');
memoBtn.innerText = "메모";
@@ -573,7 +638,19 @@
location.reload(); // 변경사항 즉시 반영
}
};
const editBtn = document.createElement('button');
editBtn.innerText = "수정";
editBtn.style.cssText = "padding: 5px 10px; background-color: #3498db; color: white; border: none; border-radius: 4px; font-size: 12px; cursor: pointer;";
editBtn.onclick = async (e) => {
e.stopPropagation(); // 뷰어로 넘어가는 현상 차단
const newTitle = prompt("새로운 제목을 입력하세요:", book.fileName);
// 취소를 누르지 않았고, 빈칸이 아닌 경우에만 업데이트
if (newTitle !== null && newTitle.trim() !== "") {
await updateBookTitleInDB(book.id, newTitle.trim());
location.reload(); // 변경사항 즉시 반영을 위해 새로고침
}
};
// 5. [삭제] 버튼 생성 및 이벤트 (기존과 동일)
const deleteBtn = document.createElement('button');
deleteBtn.innerText = "삭제";
@@ -591,7 +668,9 @@
};
// 6. 요소 조립
btnArea.appendChild(favBtn); // <-- 이거 한 줄을 맨 위에 추가!
btnArea.appendChild(memoBtn);
btnArea.appendChild(editBtn);
btnArea.appendChild(deleteBtn);
li.appendChild(titleArea);
li.appendChild(btnArea);
@@ -623,7 +702,7 @@
if (savedBook && savedBook.chapters) {
loadingText.innerText = "이전 읽던 위치로 이동 중...";
loadingOverlay.style.display = 'flex';
document.getElementById('viewer-title').innerText = savedBook.fileName;
chapterList = savedBook.chapters;
buildTocUI();
@@ -696,7 +775,8 @@
loadingText.innerText = "EPUB 압축 해제 및 목차 분석 중...";
loadingTimer1 = setTimeout(async () => {
await parseEpub(file);
const epubFullText = chapterList.slice(0, 5).map(c => c.content).join(' '); // 초반 챕터 5개만 합쳐서 검사
const extractedTags = extractTags(epubFullText);
await saveBookToDB(file.name, chapterList);
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
@@ -758,7 +838,7 @@
setTimeout(async () => {
console.log(`[파싱] 병합된 텍스트 챕터 분석 시작`);
parseChapters(combinedText);
const extractedTags = extractTags(combinedText);
await saveBookToDB(file.name, chapterList);
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
@@ -778,7 +858,7 @@
loadingTimer1 = setTimeout(async () => {
console.log(`[파싱] TXT 챕터 분석 시작`);
parseChapters(fullText);
const extractedTags = extractTags(fullText);
await saveBookToDB(file.name, chapterList);
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
@@ -1149,6 +1229,9 @@
});
}
/**
* @description 현재 계산된 페이지 번호에 맞춰 화면을 이동시키고, 진행도 및 마지막 읽은 시간을 저장합니다.
*/
/**
* @description 현재 계산된 페이지 번호에 맞춰 화면을 이동시키고, 진행도 및 마지막 읽은 시간을 저장합니다.
*/
@@ -1157,7 +1240,7 @@
const moveX = -(currentPageInChapter * window.innerWidth);
textContent.style.transform = `translate3d(${moveX}px, 0, 0)`;
// --- [추가된 전체 페이지 추정 로직] ---
// --- [전체 페이지 추정 로직] ---
let totalChars = 0;
let charsBeforeCurrent = 0;
@@ -1166,29 +1249,41 @@
if (i < currentChapterIndex) charsBeforeCurrent += chapterList[i].content.length;
}
// 현재 챕터의 페이지당 평균 글자 수를 구해 전체 페이지 유추
let charsPerPage = chapterList[currentChapterIndex].content.length / totalPagesInChapter || 500;
let estimatedTotalPages = Math.ceil(totalChars / charsPerPage);
let estimatedCurrentPage = Math.floor(charsBeforeCurrent / charsPerPage) + currentPageInChapter + 1;
pageInfo.innerText = `${currentPageInChapter + 1} / ${totalPagesInChapter} (전체 ${estimatedCurrentPage} / ${estimatedTotalPages})`;
// 1. 현재 날짜와 시간 구하기 (예: 2026-08-03 16:51)
// 1. 현재 날짜와 시간 구하기
const now = new Date();
const timeString = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
// 2. 기존 데이터 불러오기 (메모 내용 유지를 위함)
// 2. 기존 데이터 불러오기 (메모, 즐겨찾기, 기존 태그 유지)
const existingData = JSON.parse(localStorage.getItem(PROGRESS_KEY)) || {};
// 3. 진행도, 마지막 읽은 시간, 메모를 함께 묶어서 저장
// ⭐ [태그 누적 업데이트 로직 추가]
// 현재 챕터 본문 가져오기
const currentChapContent = chapterList[currentChapterIndex] ? chapterList[currentChapterIndex].content : '';
// 현재 챕터 본문에서 새로 발견된 태그 추출
const newChapterTags = extractTags(currentChapContent);
// 기존 태그 배열과 현재 챕터 태그 배열을 합치고 중복 제거 (Set 활용)
const existingTags = existingData.tags || [];
const mergedTags = Array.from(new Set([...existingTags, ...newChapterTags]));
// 3. 진행도, 시간, 메모, 즐겨찾기, 누적 태그를 함께 저장
localStorage.setItem(PROGRESS_KEY, JSON.stringify({
chapter: currentChapterIndex,
page: currentPageInChapter,
lastRead: timeString, // 갱신된 시간
memo: existingData.memo || '' // 기존 메모 유지 (없으면 빈칸)
lastRead: timeString, // 갱신된 시간
memo: existingData.memo || '', // 기존 메모 유지
isFavorite: existingData.isFavorite || false, // 즐겨찾기 유지
tags: mergedTags // ⭐ 자동 갱신 및 누적된 태그 저장
}));
console.log(`[렌더링] 뷰 업데이트: 챕터 ${currentChapterIndex} / 페이지 ${currentPageInChapter + 1} (시간 갱신: ${timeString})`);
console.log(`[렌더링] 뷰 업데이트: 챕터 ${currentChapterIndex} / 페이지 ${currentPageInChapter + 1} (태그 수: ${mergedTags.length})`);
setTimeout(() => { isAnimating = false; }, 250);
}
@@ -1349,6 +1444,144 @@
return fixed;
}
/**
* @description 기존 책의 제목(fileName)만 수정하여 DB에 덮어씁니다.
*/
async function updateBookTitleInDB(id, newTitle) {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const request = store.get(id);
request.onsuccess = () => {
const data = request.result;
if (data) {
data.fileName = newTitle;
store.put(data); // 기존 데이터 덮어쓰기
}
};
tx.oncomplete = () => {
console.log(`[DB] 제목 수정 완료 (ID: ${id} -> ${newTitle})`);
resolve();
};
tx.onerror = (e) => reject(e.target.error);
});
}
const DEFAULT_KEYWORD_RULES = {
"🐉 판타지": ["마법", "오크", "엘프", "드래곤", "제국", "마왕"],
"⚔️ 무협": ["무림", "천마", "검기", "내공", "혈교", "무당파"],
"🏢 현판": ["헌터", "게이트", "각성", "시스템", "상태창", "던전"],
"💕 로맨스": ["황제", "공작", "백작", "영애", "황태자", "정략결혼"],
"🚀 SF": ["우주선", "안드로이드", "인공지능", "사이보그", "은하제국"]
};
/**
* @description 저장된 키워드 규칙을 불러오거나 기본값을 반환합니다.
*/
function getKeywordRules() {
const saved = localStorage.getItem('webReader_keywordRules');
return saved ? JSON.parse(saved) : DEFAULT_KEYWORD_RULES;
}
/**
* @description 텍스트 본문을 스캔하여 동적으로 설정된 키워드가 포함되어 있으면 태그 배열을 반환합니다.
*/
function extractTags(text) {
const tags = [];
if (!text) return tags;
const keywordRules = getKeywordRules();
const sampleText = text.substring(0, 100000); // 상위 10만 자 스캔
for (const [tag, keywords] of Object.entries(keywordRules)) {
if (Array.isArray(keywords) && keywords.some(kw => kw.trim() && sampleText.includes(kw.trim()))) {
tags.push(tag);
}
}
return tags;
}
const keywordModal = document.getElementById('keyword-modal');
const keywordManageBtn = document.getElementById('btn-manage-keywords');
const keywordCloseBtn = document.getElementById('keyword-close-btn');
const keywordRulesList = document.getElementById('keyword-rules-list');
const btnAddKeywordRule = document.getElementById('btn-add-keyword-rule');
const btnResetKeywordRules = document.getElementById('btn-reset-keyword-rules');
const btnSaveKeywordRules = document.getElementById('btn-save-keyword-rules');
// 모달 열기
keywordManageBtn.addEventListener('click', () => {
renderKeywordRulesUI(getKeywordRules());
keywordModal.style.display = 'flex';
});
// 모달 닫기
keywordCloseBtn.addEventListener('click', () => keywordModal.style.display = 'none');
keywordModal.addEventListener('click', (e) => { if (e.target === keywordModal) keywordModal.style.display = 'none'; });
/**
* @description 현재 키워드 규칙 데이터를 모달의 입력폼 목록으로 렌더링합니다.
*/
function renderKeywordRulesUI(rules) {
keywordRulesList.innerHTML = '';
for (const [tag, keywords] of Object.entries(rules)) {
addKeywordRuleRow(tag, Array.isArray(keywords) ? keywords.join(', ') : keywords);
}
}
/**
* @description 규칙 입력 행(Row) 1개를 UI에 추가합니다.
*/
function addKeywordRuleRow(tag = '', keywordsStr = '') {
const row = document.createElement('div');
row.className = 'keyword-rule-row';
row.style.cssText = "display: flex; gap: 6px; margin-bottom: 8px; align-items: center;";
row.innerHTML = `
<input type="text" class="rule-tag-input" placeholder="태그명 (예: 🐉 판타지)" value="${tag}" style="width: 30%; padding: 6px; border: 1px solid var(--border-color); background: var(--modal-content-bg); color: var(--text-color); border-radius: 4px; font-size: 12px;">
<input type="text" class="rule-keywords-input" placeholder="키워드 (쉼표로 구분)" value="${keywordsStr}" style="flex: 1; padding: 6px; border: 1px solid var(--border-color); background: var(--modal-content-bg); color: var(--text-color); border-radius: 4px; font-size: 12px;">
<button class="btn-del-rule" style="padding: 6px 9px; background: #e74c3c; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">✕</button>
`;
row.querySelector('.btn-del-rule').addEventListener('click', () => row.remove());
keywordRulesList.appendChild(row);
}
// 새 태그 추가 버튼
btnAddKeywordRule.addEventListener('click', () => {
addKeywordRuleRow();
keywordRulesList.scrollTop = keywordRulesList.scrollHeight; // 맨 아래로 스크롤
});
// 초기화 버튼
btnResetKeywordRules.addEventListener('click', () => {
if (confirm("기본 태그/키워드 설정으로 복원하시겠습니까?")) {
renderKeywordRulesUI(DEFAULT_KEYWORD_RULES);
}
});
// 저장하기 버튼
btnSaveKeywordRules.addEventListener('click', () => {
const rows = keywordRulesList.querySelectorAll('.keyword-rule-row');
const newRules = {};
rows.forEach(row => {
const tagVal = row.querySelector('.rule-tag-input').value.trim();
const keywordsVal = row.querySelector('.rule-keywords-input').value.trim();
if (tagVal && keywordsVal) {
// 쉼표 단위로 나누어 키워드 배열 생성
const kwArray = keywordsVal.split(',').map(k => k.trim()).filter(k => k.length > 0);
newRules[tagVal] = kwArray;
}
});
localStorage.setItem('webReader_keywordRules', JSON.stringify(newRules));
alert("태그 및 키워드 설정이 저장되었습니다.");
keywordModal.style.display = 'none';
});
</script>
</body>
</html>
+266 -33
View File
@@ -60,7 +60,9 @@
transition: background-color 0.3s, border-color 0.3s;
}
.header-left, .header-right { display: flex; align-items: center; gap: 12px; }
.header-left { flex: 1; display: flex; align-items: center; gap: 12px; }
.header-center { flex: 2; text-align: center; font-weight: bold; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 0 10px; color: var(--text-color); }
.header-right { flex: 1; display: flex; align-items: center; justify-content: flex-end; gap: 12px; }
.icon-btn {
cursor: pointer;
@@ -244,6 +246,8 @@
<span class="icon-btn" id="btn-font-plus" title="글자 크게">A+</span>
<span class="icon-btn" id="btn-theme" title="테마 변경">☀️</span>
</div>
<div class="header-center" id="viewer-title">
</div>
<div class="header-right">
<span id="btn-return-library">서재로 돌아가기</span>
</div>
@@ -260,6 +264,8 @@
<button id="btn-backup" style="flex: 1; padding: 10px; background: #27ae60; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: bold;">📥 백업하기</button>
<button id="btn-restore" style="flex: 1; padding: 10px; background: #8e44ad; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: bold;">📤 복원하기</button>
</div>
<button id="btn-manage-keywords" style="margin-top: 10px; width: 100%; padding: 10px; background: #e67e22; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: bold;">🏷️ 태그 및 키워드 관리</button>
<input type="file" id="restore-input" accept=".json" style="display: none;">
</div>
</div>
@@ -302,6 +308,28 @@
<span id="page-info">0 / 0</span>
</div>
<div id="keyword-modal" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 200; align-items: center; justify-content: center;">
<div class="box" style="width: 90%; max-width: 500px; max-height: 80vh; display: flex; flex-direction: column; padding: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; border-bottom: 1px solid var(--border-color); padding-bottom: 10px;">
<h2 style="margin: 0; font-size: 17px;">🏷️ 태그 및 키워드 관리</h2>
<span id="keyword-close-btn" style="cursor: pointer; font-size: 22px; color: #888;">&times;</span>
</div>
<p style="font-size: 12px; color: #aaa; margin: 0 0 10px 0; text-align: left;">
태그명과 감지할 키워드(쉼표로 구분)를 설정하세요.
</p>
<div id="keyword-rules-list" style="overflow-y: auto; flex: 1; text-align: left; margin-bottom: 15px; padding-right: 5px;">
</div>
<div style="display: flex; gap: 8px;">
<button id="btn-add-keyword-rule" style="flex: 1; padding: 10px; background: #2980b9; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">+ 태그 추가</button>
<button id="btn-reset-keyword-rules" style="padding: 10px; background: #7f8c8d; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">초기화</button>
<button id="btn-save-keyword-rules" style="flex: 1; padding: 10px; background: #27ae60; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">저장하기</button>
</div>
</div>
</div>
<script>
// ----------------------------------------------------------------
// [0] 초기 변수 및 URL 파라미터 세팅
@@ -508,14 +536,21 @@
const savedDataA = JSON.parse(localStorage.getItem(pKeyA)) || {};
const savedDataB = JSON.parse(localStorage.getItem(pKeyB)) || {};
// lastReadTime(타임스탬프) 우선 비교 -> 없으면 lastRead(문자열 날짜) 파싱 -> 둘 다 없으면 0
// 1순위: 즐겨찾기 우선 정렬 (true인 것이 앞으로 오게)
const favA = savedDataA.isFavorite ? 1 : 0;
const favB = savedDataB.isFavorite ? 1 : 0;
if (favA !== favB) {
return favB - favA;
}
// 2순위: 즐겨찾기 여부가 같다면 기존처럼 최근 열람 시간순 정렬
const getTime = (data) => {
if (data.lastReadTime) return data.lastReadTime;
if (data.lastRead) return new Date(data.lastRead.replace(/-/g, '/')).getTime();
return 0;
};
return getTime(savedDataB) - getTime(savedDataA); // 내림차순 정렬 (최근 읽은 책이 맨 앞으로)
return getTime(savedDataB) - getTime(savedDataA);
});
libraryList.innerHTML = '';
@@ -523,32 +558,49 @@
libraryList.innerHTML = '<li style="padding: 20px; text-align: center; color: #888;">저장된 책이 없습니다.</li>';
} else {
allBooks.forEach(book => {
const li = document.createElement('li');
li.className = 'library-item';
const li = document.createElement('li');
li.className = 'library-item';
// 1. 로컬 스토리지에서 진행도, 시간, 메모 데이터 불러오기
const pKey = `webReader_progress_${book.id}`;
const savedData = JSON.parse(localStorage.getItem(pKey)) || {};
// 1. 로컬 스토리지 데이터 불러오기
const pKey = `webReader_progress_${book.id}`;
const savedData = JSON.parse(localStorage.getItem(pKey)) || {};
let progressText = savedData.chapter !== undefined
? `<div style="font-size:12px; color:#888; margin-left: 8px;">(읽는 중: Ch.${savedData.chapter + 1})</div>`
: '';
// ⭐ [추가] 총 챕터 수 및 총 글자 수 계산
const totalChapters = book.chapters ? book.chapters.length : 0;
const totalChars = book.chapters ? book.chapters.reduce((sum, chap) => sum + (chap.content ? chap.content.length : 0), 0) : 0;
const formattedChars = totalChars.toLocaleString(); // 천 단위 쉼표 추가 (예: 125,430자)
let lastReadText = savedData.lastRead
? `<div style="font-size:11px; color:#aaa; margin-top: 6px;">🕒 마지막 열람: ${savedData.lastRead}</div>`
: '';
let bookStatsText = `<div style="font-size:11px; color:#aaa; margin-top: 4px;">📚 총 ${totalChapters}화 · ${formattedChars}자</div>`;
let memoText = savedData.memo
? `<div style="font-size:12px; color:#f39c12; margin-top: 4px; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">📝 ${savedData.memo}</div>`
: '';
let progressText = savedData.chapter !== undefined
? `<div style="font-size:12px; color:#888; margin-left: 8px;">(읽는 중: Ch.${savedData.chapter + 1})</div>`
: '';
// 2. 책 제목, 진행도, 시간, 메모를 담는 텍스트 영역 구성
const titleArea = document.createElement('div');
titleArea.style.cssText = "overflow: hidden; flex: 1; padding-right: 10px;";
titleArea.innerHTML = `
let lastReadText = savedData.lastRead
? `<div style="font-size:11px; color:#aaa; margin-top: 6px;">🕒 마지막 열람: ${savedData.lastRead}</div>`
: '';
let memoText = savedData.memo
? `<div style="font-size:12px; color:#f39c12; margin-top: 4px; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">📝 ${savedData.memo}</div>`
: '';
// 태그 HTML 생성
let tagsText = '';
if (savedData.tags && savedData.tags.length > 0) {
const badges = savedData.tags.map(tag =>
`<span style="display:inline-block; padding: 3px 6px; margin-right: 5px; background: rgba(0, 168, 255, 0.2); color: var(--accent-color); border-radius: 4px; font-size: 11px; font-weight: bold;">${tag}</span>`
).join('');
tagsText = `<div style="margin-top: 6px;">${badges}</div>`;
}
// 2. 책 제목, 분량 정보, 태그, 진행도, 시간, 메모 조립
const titleArea = document.createElement('div');
titleArea.style.cssText = "overflow: hidden; flex: 1; padding-right: 10px;";
titleArea.innerHTML = `
<div style="text-overflow: ellipsis; white-space: nowrap; overflow:hidden;">
<strong>${book.fileName}</strong> ${progressText}
</div>
${bookStatsText} ${tagsText}
${lastReadText}
${memoText}
`;
@@ -557,6 +609,19 @@
const btnArea = document.createElement('div');
btnArea.style.cssText = "display: flex; gap: 8px; flex-shrink: 0;";
const isFav = savedData.isFavorite || false;
const favBtn = document.createElement('button');
favBtn.innerText = isFav ? "⭐ 해제" : "☆ 즐겨찾기";
// 즐겨찾기 활성화 시 배경색을 다르게 주어 눈에 띄게 설정
favBtn.style.cssText = `padding: 5px 10px; background-color: ${isFav ? '#f1c40f' : '#7f8c8d'}; color: white; border: none; border-radius: 4px; font-size: 12px; cursor: pointer;`;
favBtn.onclick = (e) => {
e.stopPropagation(); // 뷰어로 넘어가는 현상 차단
savedData.isFavorite = !isFav; // 상태 반전 (true <-> false)
localStorage.setItem(pKey, JSON.stringify(savedData));
location.reload(); // 변경사항(정렬 및 버튼 텍스트) 즉시 반영을 위해 새로고침
};
// 4. [메모] 버튼 생성 및 이벤트
const memoBtn = document.createElement('button');
memoBtn.innerText = "메모";
@@ -573,7 +638,19 @@
location.reload(); // 변경사항 즉시 반영
}
};
const editBtn = document.createElement('button');
editBtn.innerText = "수정";
editBtn.style.cssText = "padding: 5px 10px; background-color: #3498db; color: white; border: none; border-radius: 4px; font-size: 12px; cursor: pointer;";
editBtn.onclick = async (e) => {
e.stopPropagation(); // 뷰어로 넘어가는 현상 차단
const newTitle = prompt("새로운 제목을 입력하세요:", book.fileName);
// 취소를 누르지 않았고, 빈칸이 아닌 경우에만 업데이트
if (newTitle !== null && newTitle.trim() !== "") {
await updateBookTitleInDB(book.id, newTitle.trim());
location.reload(); // 변경사항 즉시 반영을 위해 새로고침
}
};
// 5. [삭제] 버튼 생성 및 이벤트 (기존과 동일)
const deleteBtn = document.createElement('button');
deleteBtn.innerText = "삭제";
@@ -591,7 +668,9 @@
};
// 6. 요소 조립
btnArea.appendChild(favBtn); // <-- 이거 줄을 위에 추가!
btnArea.appendChild(memoBtn);
btnArea.appendChild(editBtn);
btnArea.appendChild(deleteBtn);
li.appendChild(titleArea);
li.appendChild(btnArea);
@@ -623,7 +702,7 @@
if (savedBook && savedBook.chapters) {
loadingText.innerText = "이전 읽던 위치로 이동 중...";
loadingOverlay.style.display = 'flex';
document.getElementById('viewer-title').innerText = savedBook.fileName;
chapterList = savedBook.chapters;
buildTocUI();
@@ -696,7 +775,8 @@
loadingText.innerText = "EPUB 압축 해제 및 목차 분석 중...";
loadingTimer1 = setTimeout(async () => {
await parseEpub(file);
const epubFullText = chapterList.slice(0, 5).map(c => c.content).join(' '); // 초반 챕터 5개만 합쳐서 검사
const extractedTags = extractTags(epubFullText);
await saveBookToDB(file.name, chapterList);
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
@@ -758,7 +838,7 @@
setTimeout(async () => {
console.log(`[파싱] 병합된 텍스트 챕터 분석 시작`);
parseChapters(combinedText);
const extractedTags = extractTags(combinedText);
await saveBookToDB(file.name, chapterList);
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
@@ -778,7 +858,7 @@
loadingTimer1 = setTimeout(async () => {
console.log(`[파싱] TXT 챕터 분석 시작`);
parseChapters(fullText);
const extractedTags = extractTags(fullText);
await saveBookToDB(file.name, chapterList);
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
@@ -1149,6 +1229,9 @@
});
}
/**
* @description 현재 계산된 페이지 번호에 맞춰 화면을 이동시키고, 진행도 및 마지막 읽은 시간을 저장합니다.
*/
/**
* @description 현재 계산된 페이지 번호에 맞춰 화면을 이동시키고, 진행도 및 마지막 읽은 시간을 저장합니다.
*/
@@ -1157,7 +1240,7 @@
const moveX = -(currentPageInChapter * window.innerWidth);
textContent.style.transform = `translate3d(${moveX}px, 0, 0)`;
// --- [추가된 전체 페이지 추정 로직] ---
// --- [전체 페이지 추정 로직] ---
let totalChars = 0;
let charsBeforeCurrent = 0;
@@ -1166,29 +1249,41 @@
if (i < currentChapterIndex) charsBeforeCurrent += chapterList[i].content.length;
}
// 현재 챕터의 페이지당 평균 글자 수를 구해 전체 페이지 유추
let charsPerPage = chapterList[currentChapterIndex].content.length / totalPagesInChapter || 500;
let estimatedTotalPages = Math.ceil(totalChars / charsPerPage);
let estimatedCurrentPage = Math.floor(charsBeforeCurrent / charsPerPage) + currentPageInChapter + 1;
pageInfo.innerText = `${currentPageInChapter + 1} / ${totalPagesInChapter} (전체 ${estimatedCurrentPage} / ${estimatedTotalPages})`;
// 1. 현재 날짜와 시간 구하기 (예: 2026-08-03 16:51)
// 1. 현재 날짜와 시간 구하기
const now = new Date();
const timeString = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
// 2. 기존 데이터 불러오기 (메모 내용 유지를 위함)
// 2. 기존 데이터 불러오기 (메모, 즐겨찾기, 기존 태그 유지)
const existingData = JSON.parse(localStorage.getItem(PROGRESS_KEY)) || {};
// 3. 진행도, 마지막 읽은 시간, 메모를 함께 묶어서 저장
// ⭐ [태그 누적 업데이트 로직 추가]
// 현재 챕터 본문 가져오기
const currentChapContent = chapterList[currentChapterIndex] ? chapterList[currentChapterIndex].content : '';
// 현재 챕터 본문에서 새로 발견된 태그 추출
const newChapterTags = extractTags(currentChapContent);
// 기존 태그 배열과 현재 챕터 태그 배열을 합치고 중복 제거 (Set 활용)
const existingTags = existingData.tags || [];
const mergedTags = Array.from(new Set([...existingTags, ...newChapterTags]));
// 3. 진행도, 시간, 메모, 즐겨찾기, 누적 태그를 함께 저장
localStorage.setItem(PROGRESS_KEY, JSON.stringify({
chapter: currentChapterIndex,
page: currentPageInChapter,
lastRead: timeString, // 갱신된 시간
memo: existingData.memo || '' // 기존 메모 유지 (없으면 빈칸)
lastRead: timeString, // 갱신된 시간
memo: existingData.memo || '', // 기존 메모 유지
isFavorite: existingData.isFavorite || false, // 즐겨찾기 유지
tags: mergedTags // ⭐ 자동 갱신 및 누적된 태그 저장
}));
console.log(`[렌더링] 뷰 업데이트: 챕터 ${currentChapterIndex} / 페이지 ${currentPageInChapter + 1} (시간 갱신: ${timeString})`);
console.log(`[렌더링] 뷰 업데이트: 챕터 ${currentChapterIndex} / 페이지 ${currentPageInChapter + 1} (태그 수: ${mergedTags.length}개)`);
setTimeout(() => { isAnimating = false; }, 250);
}
@@ -1349,6 +1444,144 @@
return fixed;
}
/**
* @description 기존 책의 제목(fileName)만 수정하여 DB에 덮어씁니다.
*/
async function updateBookTitleInDB(id, newTitle) {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const request = store.get(id);
request.onsuccess = () => {
const data = request.result;
if (data) {
data.fileName = newTitle;
store.put(data); // 기존 데이터 덮어쓰기
}
};
tx.oncomplete = () => {
console.log(`[DB] 제목 수정 완료 (ID: ${id} -> ${newTitle})`);
resolve();
};
tx.onerror = (e) => reject(e.target.error);
});
}
const DEFAULT_KEYWORD_RULES = {
"🐉 판타지": ["마법", "오크", "엘프", "드래곤", "제국", "마왕"],
"⚔️ 무협": ["무림", "천마", "검기", "내공", "혈교", "무당파"],
"🏢 현판": ["헌터", "게이트", "각성", "시스템", "상태창", "던전"],
"💕 로맨스": ["황제", "공작", "백작", "영애", "황태자", "정략결혼"],
"🚀 SF": ["우주선", "안드로이드", "인공지능", "사이보그", "은하제국"]
};
/**
* @description 저장된 키워드 규칙을 불러오거나 기본값을 반환합니다.
*/
function getKeywordRules() {
const saved = localStorage.getItem('webReader_keywordRules');
return saved ? JSON.parse(saved) : DEFAULT_KEYWORD_RULES;
}
/**
* @description 텍스트 본문을 스캔하여 동적으로 설정된 키워드가 포함되어 있으면 태그 배열을 반환합니다.
*/
function extractTags(text) {
const tags = [];
if (!text) return tags;
const keywordRules = getKeywordRules();
const sampleText = text.substring(0, 100000); // 상위 10만 자 스캔
for (const [tag, keywords] of Object.entries(keywordRules)) {
if (Array.isArray(keywords) && keywords.some(kw => kw.trim() && sampleText.includes(kw.trim()))) {
tags.push(tag);
}
}
return tags;
}
const keywordModal = document.getElementById('keyword-modal');
const keywordManageBtn = document.getElementById('btn-manage-keywords');
const keywordCloseBtn = document.getElementById('keyword-close-btn');
const keywordRulesList = document.getElementById('keyword-rules-list');
const btnAddKeywordRule = document.getElementById('btn-add-keyword-rule');
const btnResetKeywordRules = document.getElementById('btn-reset-keyword-rules');
const btnSaveKeywordRules = document.getElementById('btn-save-keyword-rules');
// 모달 열기
keywordManageBtn.addEventListener('click', () => {
renderKeywordRulesUI(getKeywordRules());
keywordModal.style.display = 'flex';
});
// 모달 닫기
keywordCloseBtn.addEventListener('click', () => keywordModal.style.display = 'none');
keywordModal.addEventListener('click', (e) => { if (e.target === keywordModal) keywordModal.style.display = 'none'; });
/**
* @description 현재 키워드 규칙 데이터를 모달의 입력폼 목록으로 렌더링합니다.
*/
function renderKeywordRulesUI(rules) {
keywordRulesList.innerHTML = '';
for (const [tag, keywords] of Object.entries(rules)) {
addKeywordRuleRow(tag, Array.isArray(keywords) ? keywords.join(', ') : keywords);
}
}
/**
* @description 규칙 입력 행(Row) 1개를 UI에 추가합니다.
*/
function addKeywordRuleRow(tag = '', keywordsStr = '') {
const row = document.createElement('div');
row.className = 'keyword-rule-row';
row.style.cssText = "display: flex; gap: 6px; margin-bottom: 8px; align-items: center;";
row.innerHTML = `
<input type="text" class="rule-tag-input" placeholder="태그명 (예: 🐉 판타지)" value="${tag}" style="width: 30%; padding: 6px; border: 1px solid var(--border-color); background: var(--modal-content-bg); color: var(--text-color); border-radius: 4px; font-size: 12px;">
<input type="text" class="rule-keywords-input" placeholder="키워드 (쉼표로 구분)" value="${keywordsStr}" style="flex: 1; padding: 6px; border: 1px solid var(--border-color); background: var(--modal-content-bg); color: var(--text-color); border-radius: 4px; font-size: 12px;">
<button class="btn-del-rule" style="padding: 6px 9px; background: #e74c3c; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;"></button>
`;
row.querySelector('.btn-del-rule').addEventListener('click', () => row.remove());
keywordRulesList.appendChild(row);
}
// 새 태그 추가 버튼
btnAddKeywordRule.addEventListener('click', () => {
addKeywordRuleRow();
keywordRulesList.scrollTop = keywordRulesList.scrollHeight; // 맨 아래로 스크롤
});
// 초기화 버튼
btnResetKeywordRules.addEventListener('click', () => {
if (confirm("기본 태그/키워드 설정으로 복원하시겠습니까?")) {
renderKeywordRulesUI(DEFAULT_KEYWORD_RULES);
}
});
// 저장하기 버튼
btnSaveKeywordRules.addEventListener('click', () => {
const rows = keywordRulesList.querySelectorAll('.keyword-rule-row');
const newRules = {};
rows.forEach(row => {
const tagVal = row.querySelector('.rule-tag-input').value.trim();
const keywordsVal = row.querySelector('.rule-keywords-input').value.trim();
if (tagVal && keywordsVal) {
// 쉼표 단위로 나누어 키워드 배열 생성
const kwArray = keywordsVal.split(',').map(k => k.trim()).filter(k => k.length > 0);
newRules[tagVal] = kwArray;
}
});
localStorage.setItem('webReader_keywordRules', JSON.stringify(newRules));
alert("태그 및 키워드 설정이 저장되었습니다.");
keywordModal.style.display = 'none';
});
</script>
</body>
</html>
@@ -114,6 +114,7 @@ class WebReaderActivity : AppCompatActivity() {
private fun setupWebView() {
webView.settings.apply {
cacheMode = WebSettings.LOAD_NO_CACHE
javaScriptEnabled = true
domStorageEnabled = true
allowFileAccess = true