...
This commit is contained in:
@@ -60,7 +60,9 @@
|
|||||||
transition: background-color 0.3s, border-color 0.3s;
|
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 {
|
.icon-btn {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -244,6 +246,8 @@
|
|||||||
<span class="icon-btn" id="btn-font-plus" title="글자 크게">A+</span>
|
<span class="icon-btn" id="btn-font-plus" title="글자 크게">A+</span>
|
||||||
<span class="icon-btn" id="btn-theme" title="테마 변경">☀️</span>
|
<span class="icon-btn" id="btn-theme" title="테마 변경">☀️</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="header-center" id="viewer-title">
|
||||||
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<span id="btn-return-library">서재로 돌아가기</span>
|
<span id="btn-return-library">서재로 돌아가기</span>
|
||||||
</div>
|
</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-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>
|
<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>
|
</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;">
|
<input type="file" id="restore-input" accept=".json" style="display: none;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -302,6 +308,28 @@
|
|||||||
<span id="page-info">0 / 0</span>
|
<span id="page-info">0 / 0</span>
|
||||||
</div>
|
</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;">×</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>
|
<script>
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
// [0] 초기 변수 및 URL 파라미터 세팅
|
// [0] 초기 변수 및 URL 파라미터 세팅
|
||||||
@@ -508,14 +536,21 @@
|
|||||||
const savedDataA = JSON.parse(localStorage.getItem(pKeyA)) || {};
|
const savedDataA = JSON.parse(localStorage.getItem(pKeyA)) || {};
|
||||||
const savedDataB = JSON.parse(localStorage.getItem(pKeyB)) || {};
|
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) => {
|
const getTime = (data) => {
|
||||||
if (data.lastReadTime) return data.lastReadTime;
|
if (data.lastReadTime) return data.lastReadTime;
|
||||||
if (data.lastRead) return new Date(data.lastRead.replace(/-/g, '/')).getTime();
|
if (data.lastRead) return new Date(data.lastRead.replace(/-/g, '/')).getTime();
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
return getTime(savedDataB) - getTime(savedDataA); // 내림차순 정렬 (최근 읽은 책이 맨 앞으로)
|
return getTime(savedDataB) - getTime(savedDataA);
|
||||||
});
|
});
|
||||||
libraryList.innerHTML = '';
|
libraryList.innerHTML = '';
|
||||||
|
|
||||||
@@ -526,10 +561,17 @@
|
|||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'library-item';
|
li.className = 'library-item';
|
||||||
|
|
||||||
// 1. 로컬 스토리지에서 진행도, 시간, 메모 데이터 불러오기
|
// 1. 로컬 스토리지 데이터 불러오기
|
||||||
const pKey = `webReader_progress_${book.id}`;
|
const pKey = `webReader_progress_${book.id}`;
|
||||||
const savedData = JSON.parse(localStorage.getItem(pKey)) || {};
|
const savedData = JSON.parse(localStorage.getItem(pKey)) || {};
|
||||||
|
|
||||||
|
// ⭐ [추가] 총 챕터 수 및 총 글자 수 계산
|
||||||
|
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 bookStatsText = `<div style="font-size:11px; color:#aaa; margin-top: 4px;">📚 총 ${totalChapters}화 · ${formattedChars}자</div>`;
|
||||||
|
|
||||||
let progressText = savedData.chapter !== undefined
|
let progressText = savedData.chapter !== undefined
|
||||||
? `<div style="font-size:12px; color:#888; margin-left: 8px;">(읽는 중: Ch.${savedData.chapter + 1})</div>`
|
? `<div style="font-size:12px; color:#888; margin-left: 8px;">(읽는 중: Ch.${savedData.chapter + 1})</div>`
|
||||||
: '';
|
: '';
|
||||||
@@ -542,13 +584,23 @@
|
|||||||
? `<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>`
|
? `<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>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// 2. 책 제목, 진행도, 시간, 메모를 담는 텍스트 영역 구성
|
// 태그 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');
|
const titleArea = document.createElement('div');
|
||||||
titleArea.style.cssText = "overflow: hidden; flex: 1; padding-right: 10px;";
|
titleArea.style.cssText = "overflow: hidden; flex: 1; padding-right: 10px;";
|
||||||
titleArea.innerHTML = `
|
titleArea.innerHTML = `
|
||||||
<div style="text-overflow: ellipsis; white-space: nowrap; overflow:hidden;">
|
<div style="text-overflow: ellipsis; white-space: nowrap; overflow:hidden;">
|
||||||
<strong>${book.fileName}</strong> ${progressText}
|
<strong>${book.fileName}</strong> ${progressText}
|
||||||
</div>
|
</div>
|
||||||
|
${bookStatsText} ${tagsText}
|
||||||
${lastReadText}
|
${lastReadText}
|
||||||
${memoText}
|
${memoText}
|
||||||
`;
|
`;
|
||||||
@@ -557,6 +609,19 @@
|
|||||||
const btnArea = document.createElement('div');
|
const btnArea = document.createElement('div');
|
||||||
btnArea.style.cssText = "display: flex; gap: 8px; flex-shrink: 0;";
|
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. [메모] 버튼 생성 및 이벤트
|
// 4. [메모] 버튼 생성 및 이벤트
|
||||||
const memoBtn = document.createElement('button');
|
const memoBtn = document.createElement('button');
|
||||||
memoBtn.innerText = "메모";
|
memoBtn.innerText = "메모";
|
||||||
@@ -573,7 +638,19 @@
|
|||||||
location.reload(); // 변경사항 즉시 반영
|
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. [삭제] 버튼 생성 및 이벤트 (기존과 동일)
|
// 5. [삭제] 버튼 생성 및 이벤트 (기존과 동일)
|
||||||
const deleteBtn = document.createElement('button');
|
const deleteBtn = document.createElement('button');
|
||||||
deleteBtn.innerText = "삭제";
|
deleteBtn.innerText = "삭제";
|
||||||
@@ -591,7 +668,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 6. 요소 조립
|
// 6. 요소 조립
|
||||||
|
btnArea.appendChild(favBtn); // <-- 이거 한 줄을 맨 위에 추가!
|
||||||
btnArea.appendChild(memoBtn);
|
btnArea.appendChild(memoBtn);
|
||||||
|
btnArea.appendChild(editBtn);
|
||||||
btnArea.appendChild(deleteBtn);
|
btnArea.appendChild(deleteBtn);
|
||||||
li.appendChild(titleArea);
|
li.appendChild(titleArea);
|
||||||
li.appendChild(btnArea);
|
li.appendChild(btnArea);
|
||||||
@@ -623,7 +702,7 @@
|
|||||||
if (savedBook && savedBook.chapters) {
|
if (savedBook && savedBook.chapters) {
|
||||||
loadingText.innerText = "이전 읽던 위치로 이동 중...";
|
loadingText.innerText = "이전 읽던 위치로 이동 중...";
|
||||||
loadingOverlay.style.display = 'flex';
|
loadingOverlay.style.display = 'flex';
|
||||||
|
document.getElementById('viewer-title').innerText = savedBook.fileName;
|
||||||
chapterList = savedBook.chapters;
|
chapterList = savedBook.chapters;
|
||||||
buildTocUI();
|
buildTocUI();
|
||||||
|
|
||||||
@@ -696,7 +775,8 @@
|
|||||||
loadingText.innerText = "EPUB 압축 해제 및 목차 분석 중...";
|
loadingText.innerText = "EPUB 압축 해제 및 목차 분석 중...";
|
||||||
loadingTimer1 = setTimeout(async () => {
|
loadingTimer1 = setTimeout(async () => {
|
||||||
await parseEpub(file);
|
await parseEpub(file);
|
||||||
|
const epubFullText = chapterList.slice(0, 5).map(c => c.content).join(' '); // 초반 챕터 5개만 합쳐서 검사
|
||||||
|
const extractedTags = extractTags(epubFullText);
|
||||||
await saveBookToDB(file.name, chapterList);
|
await saveBookToDB(file.name, chapterList);
|
||||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
||||||
|
|
||||||
@@ -758,7 +838,7 @@
|
|||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
console.log(`[파싱] 병합된 텍스트 챕터 분석 시작`);
|
console.log(`[파싱] 병합된 텍스트 챕터 분석 시작`);
|
||||||
parseChapters(combinedText);
|
parseChapters(combinedText);
|
||||||
|
const extractedTags = extractTags(combinedText);
|
||||||
await saveBookToDB(file.name, chapterList);
|
await saveBookToDB(file.name, chapterList);
|
||||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
||||||
|
|
||||||
@@ -778,7 +858,7 @@
|
|||||||
loadingTimer1 = setTimeout(async () => {
|
loadingTimer1 = setTimeout(async () => {
|
||||||
console.log(`[파싱] TXT 챕터 분석 시작`);
|
console.log(`[파싱] TXT 챕터 분석 시작`);
|
||||||
parseChapters(fullText);
|
parseChapters(fullText);
|
||||||
|
const extractedTags = extractTags(fullText);
|
||||||
await saveBookToDB(file.name, chapterList);
|
await saveBookToDB(file.name, chapterList);
|
||||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
||||||
|
|
||||||
@@ -1149,6 +1229,9 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 현재 계산된 페이지 번호에 맞춰 화면을 이동시키고, 진행도 및 마지막 읽은 시간을 저장합니다.
|
||||||
|
*/
|
||||||
/**
|
/**
|
||||||
* @description 현재 계산된 페이지 번호에 맞춰 화면을 이동시키고, 진행도 및 마지막 읽은 시간을 저장합니다.
|
* @description 현재 계산된 페이지 번호에 맞춰 화면을 이동시키고, 진행도 및 마지막 읽은 시간을 저장합니다.
|
||||||
*/
|
*/
|
||||||
@@ -1157,7 +1240,7 @@
|
|||||||
const moveX = -(currentPageInChapter * window.innerWidth);
|
const moveX = -(currentPageInChapter * window.innerWidth);
|
||||||
textContent.style.transform = `translate3d(${moveX}px, 0, 0)`;
|
textContent.style.transform = `translate3d(${moveX}px, 0, 0)`;
|
||||||
|
|
||||||
// --- [추가된 전체 페이지 추정 로직] ---
|
// --- [전체 페이지 추정 로직] ---
|
||||||
let totalChars = 0;
|
let totalChars = 0;
|
||||||
let charsBeforeCurrent = 0;
|
let charsBeforeCurrent = 0;
|
||||||
|
|
||||||
@@ -1166,29 +1249,41 @@
|
|||||||
if (i < currentChapterIndex) charsBeforeCurrent += chapterList[i].content.length;
|
if (i < currentChapterIndex) charsBeforeCurrent += chapterList[i].content.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 현재 챕터의 페이지당 평균 글자 수를 구해 전체 페이지 유추
|
|
||||||
let charsPerPage = chapterList[currentChapterIndex].content.length / totalPagesInChapter || 500;
|
let charsPerPage = chapterList[currentChapterIndex].content.length / totalPagesInChapter || 500;
|
||||||
let estimatedTotalPages = Math.ceil(totalChars / charsPerPage);
|
let estimatedTotalPages = Math.ceil(totalChars / charsPerPage);
|
||||||
let estimatedCurrentPage = Math.floor(charsBeforeCurrent / charsPerPage) + currentPageInChapter + 1;
|
let estimatedCurrentPage = Math.floor(charsBeforeCurrent / charsPerPage) + currentPageInChapter + 1;
|
||||||
|
|
||||||
pageInfo.innerText = `${currentPageInChapter + 1} / ${totalPagesInChapter} (전체 ${estimatedCurrentPage} / ${estimatedTotalPages})`;
|
pageInfo.innerText = `${currentPageInChapter + 1} / ${totalPagesInChapter} (전체 ${estimatedCurrentPage} / ${estimatedTotalPages})`;
|
||||||
|
|
||||||
// 1. 현재 날짜와 시간 구하기 (예: 2026-08-03 16:51)
|
// 1. 현재 날짜와 시간 구하기
|
||||||
const now = new Date();
|
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')}`;
|
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)) || {};
|
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({
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify({
|
||||||
chapter: currentChapterIndex,
|
chapter: currentChapterIndex,
|
||||||
page: currentPageInChapter,
|
page: currentPageInChapter,
|
||||||
lastRead: timeString, // 갱신된 시간
|
lastRead: timeString, // 갱신된 시간
|
||||||
memo: existingData.memo || '' // 기존 메모 유지 (없으면 빈칸)
|
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);
|
setTimeout(() => { isAnimating = false; }, 250);
|
||||||
}
|
}
|
||||||
@@ -1349,6 +1444,144 @@
|
|||||||
|
|
||||||
return fixed;
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+249
-16
@@ -60,7 +60,9 @@
|
|||||||
transition: background-color 0.3s, border-color 0.3s;
|
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 {
|
.icon-btn {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -244,6 +246,8 @@
|
|||||||
<span class="icon-btn" id="btn-font-plus" title="글자 크게">A+</span>
|
<span class="icon-btn" id="btn-font-plus" title="글자 크게">A+</span>
|
||||||
<span class="icon-btn" id="btn-theme" title="테마 변경">☀️</span>
|
<span class="icon-btn" id="btn-theme" title="테마 변경">☀️</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="header-center" id="viewer-title">
|
||||||
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<span id="btn-return-library">서재로 돌아가기</span>
|
<span id="btn-return-library">서재로 돌아가기</span>
|
||||||
</div>
|
</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-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>
|
<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>
|
</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;">
|
<input type="file" id="restore-input" accept=".json" style="display: none;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -302,6 +308,28 @@
|
|||||||
<span id="page-info">0 / 0</span>
|
<span id="page-info">0 / 0</span>
|
||||||
</div>
|
</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;">×</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>
|
<script>
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
// [0] 초기 변수 및 URL 파라미터 세팅
|
// [0] 초기 변수 및 URL 파라미터 세팅
|
||||||
@@ -508,14 +536,21 @@
|
|||||||
const savedDataA = JSON.parse(localStorage.getItem(pKeyA)) || {};
|
const savedDataA = JSON.parse(localStorage.getItem(pKeyA)) || {};
|
||||||
const savedDataB = JSON.parse(localStorage.getItem(pKeyB)) || {};
|
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) => {
|
const getTime = (data) => {
|
||||||
if (data.lastReadTime) return data.lastReadTime;
|
if (data.lastReadTime) return data.lastReadTime;
|
||||||
if (data.lastRead) return new Date(data.lastRead.replace(/-/g, '/')).getTime();
|
if (data.lastRead) return new Date(data.lastRead.replace(/-/g, '/')).getTime();
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
return getTime(savedDataB) - getTime(savedDataA); // 내림차순 정렬 (최근 읽은 책이 맨 앞으로)
|
return getTime(savedDataB) - getTime(savedDataA);
|
||||||
});
|
});
|
||||||
libraryList.innerHTML = '';
|
libraryList.innerHTML = '';
|
||||||
|
|
||||||
@@ -526,10 +561,17 @@
|
|||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'library-item';
|
li.className = 'library-item';
|
||||||
|
|
||||||
// 1. 로컬 스토리지에서 진행도, 시간, 메모 데이터 불러오기
|
// 1. 로컬 스토리지 데이터 불러오기
|
||||||
const pKey = `webReader_progress_${book.id}`;
|
const pKey = `webReader_progress_${book.id}`;
|
||||||
const savedData = JSON.parse(localStorage.getItem(pKey)) || {};
|
const savedData = JSON.parse(localStorage.getItem(pKey)) || {};
|
||||||
|
|
||||||
|
// ⭐ [추가] 총 챕터 수 및 총 글자 수 계산
|
||||||
|
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 bookStatsText = `<div style="font-size:11px; color:#aaa; margin-top: 4px;">📚 총 ${totalChapters}화 · ${formattedChars}자</div>`;
|
||||||
|
|
||||||
let progressText = savedData.chapter !== undefined
|
let progressText = savedData.chapter !== undefined
|
||||||
? `<div style="font-size:12px; color:#888; margin-left: 8px;">(읽는 중: Ch.${savedData.chapter + 1})</div>`
|
? `<div style="font-size:12px; color:#888; margin-left: 8px;">(읽는 중: Ch.${savedData.chapter + 1})</div>`
|
||||||
: '';
|
: '';
|
||||||
@@ -542,13 +584,23 @@
|
|||||||
? `<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>`
|
? `<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>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// 2. 책 제목, 진행도, 시간, 메모를 담는 텍스트 영역 구성
|
// 태그 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');
|
const titleArea = document.createElement('div');
|
||||||
titleArea.style.cssText = "overflow: hidden; flex: 1; padding-right: 10px;";
|
titleArea.style.cssText = "overflow: hidden; flex: 1; padding-right: 10px;";
|
||||||
titleArea.innerHTML = `
|
titleArea.innerHTML = `
|
||||||
<div style="text-overflow: ellipsis; white-space: nowrap; overflow:hidden;">
|
<div style="text-overflow: ellipsis; white-space: nowrap; overflow:hidden;">
|
||||||
<strong>${book.fileName}</strong> ${progressText}
|
<strong>${book.fileName}</strong> ${progressText}
|
||||||
</div>
|
</div>
|
||||||
|
${bookStatsText} ${tagsText}
|
||||||
${lastReadText}
|
${lastReadText}
|
||||||
${memoText}
|
${memoText}
|
||||||
`;
|
`;
|
||||||
@@ -557,6 +609,19 @@
|
|||||||
const btnArea = document.createElement('div');
|
const btnArea = document.createElement('div');
|
||||||
btnArea.style.cssText = "display: flex; gap: 8px; flex-shrink: 0;";
|
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. [메모] 버튼 생성 및 이벤트
|
// 4. [메모] 버튼 생성 및 이벤트
|
||||||
const memoBtn = document.createElement('button');
|
const memoBtn = document.createElement('button');
|
||||||
memoBtn.innerText = "메모";
|
memoBtn.innerText = "메모";
|
||||||
@@ -573,7 +638,19 @@
|
|||||||
location.reload(); // 변경사항 즉시 반영
|
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. [삭제] 버튼 생성 및 이벤트 (기존과 동일)
|
// 5. [삭제] 버튼 생성 및 이벤트 (기존과 동일)
|
||||||
const deleteBtn = document.createElement('button');
|
const deleteBtn = document.createElement('button');
|
||||||
deleteBtn.innerText = "삭제";
|
deleteBtn.innerText = "삭제";
|
||||||
@@ -591,7 +668,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 6. 요소 조립
|
// 6. 요소 조립
|
||||||
|
btnArea.appendChild(favBtn); // <-- 이거 한 줄을 맨 위에 추가!
|
||||||
btnArea.appendChild(memoBtn);
|
btnArea.appendChild(memoBtn);
|
||||||
|
btnArea.appendChild(editBtn);
|
||||||
btnArea.appendChild(deleteBtn);
|
btnArea.appendChild(deleteBtn);
|
||||||
li.appendChild(titleArea);
|
li.appendChild(titleArea);
|
||||||
li.appendChild(btnArea);
|
li.appendChild(btnArea);
|
||||||
@@ -623,7 +702,7 @@
|
|||||||
if (savedBook && savedBook.chapters) {
|
if (savedBook && savedBook.chapters) {
|
||||||
loadingText.innerText = "이전 읽던 위치로 이동 중...";
|
loadingText.innerText = "이전 읽던 위치로 이동 중...";
|
||||||
loadingOverlay.style.display = 'flex';
|
loadingOverlay.style.display = 'flex';
|
||||||
|
document.getElementById('viewer-title').innerText = savedBook.fileName;
|
||||||
chapterList = savedBook.chapters;
|
chapterList = savedBook.chapters;
|
||||||
buildTocUI();
|
buildTocUI();
|
||||||
|
|
||||||
@@ -696,7 +775,8 @@
|
|||||||
loadingText.innerText = "EPUB 압축 해제 및 목차 분석 중...";
|
loadingText.innerText = "EPUB 압축 해제 및 목차 분석 중...";
|
||||||
loadingTimer1 = setTimeout(async () => {
|
loadingTimer1 = setTimeout(async () => {
|
||||||
await parseEpub(file);
|
await parseEpub(file);
|
||||||
|
const epubFullText = chapterList.slice(0, 5).map(c => c.content).join(' '); // 초반 챕터 5개만 합쳐서 검사
|
||||||
|
const extractedTags = extractTags(epubFullText);
|
||||||
await saveBookToDB(file.name, chapterList);
|
await saveBookToDB(file.name, chapterList);
|
||||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
||||||
|
|
||||||
@@ -758,7 +838,7 @@
|
|||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
console.log(`[파싱] 병합된 텍스트 챕터 분석 시작`);
|
console.log(`[파싱] 병합된 텍스트 챕터 분석 시작`);
|
||||||
parseChapters(combinedText);
|
parseChapters(combinedText);
|
||||||
|
const extractedTags = extractTags(combinedText);
|
||||||
await saveBookToDB(file.name, chapterList);
|
await saveBookToDB(file.name, chapterList);
|
||||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
||||||
|
|
||||||
@@ -778,7 +858,7 @@
|
|||||||
loadingTimer1 = setTimeout(async () => {
|
loadingTimer1 = setTimeout(async () => {
|
||||||
console.log(`[파싱] TXT 챕터 분석 시작`);
|
console.log(`[파싱] TXT 챕터 분석 시작`);
|
||||||
parseChapters(fullText);
|
parseChapters(fullText);
|
||||||
|
const extractedTags = extractTags(fullText);
|
||||||
await saveBookToDB(file.name, chapterList);
|
await saveBookToDB(file.name, chapterList);
|
||||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
||||||
|
|
||||||
@@ -1149,6 +1229,9 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 현재 계산된 페이지 번호에 맞춰 화면을 이동시키고, 진행도 및 마지막 읽은 시간을 저장합니다.
|
||||||
|
*/
|
||||||
/**
|
/**
|
||||||
* @description 현재 계산된 페이지 번호에 맞춰 화면을 이동시키고, 진행도 및 마지막 읽은 시간을 저장합니다.
|
* @description 현재 계산된 페이지 번호에 맞춰 화면을 이동시키고, 진행도 및 마지막 읽은 시간을 저장합니다.
|
||||||
*/
|
*/
|
||||||
@@ -1157,7 +1240,7 @@
|
|||||||
const moveX = -(currentPageInChapter * window.innerWidth);
|
const moveX = -(currentPageInChapter * window.innerWidth);
|
||||||
textContent.style.transform = `translate3d(${moveX}px, 0, 0)`;
|
textContent.style.transform = `translate3d(${moveX}px, 0, 0)`;
|
||||||
|
|
||||||
// --- [추가된 전체 페이지 추정 로직] ---
|
// --- [전체 페이지 추정 로직] ---
|
||||||
let totalChars = 0;
|
let totalChars = 0;
|
||||||
let charsBeforeCurrent = 0;
|
let charsBeforeCurrent = 0;
|
||||||
|
|
||||||
@@ -1166,29 +1249,41 @@
|
|||||||
if (i < currentChapterIndex) charsBeforeCurrent += chapterList[i].content.length;
|
if (i < currentChapterIndex) charsBeforeCurrent += chapterList[i].content.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 현재 챕터의 페이지당 평균 글자 수를 구해 전체 페이지 유추
|
|
||||||
let charsPerPage = chapterList[currentChapterIndex].content.length / totalPagesInChapter || 500;
|
let charsPerPage = chapterList[currentChapterIndex].content.length / totalPagesInChapter || 500;
|
||||||
let estimatedTotalPages = Math.ceil(totalChars / charsPerPage);
|
let estimatedTotalPages = Math.ceil(totalChars / charsPerPage);
|
||||||
let estimatedCurrentPage = Math.floor(charsBeforeCurrent / charsPerPage) + currentPageInChapter + 1;
|
let estimatedCurrentPage = Math.floor(charsBeforeCurrent / charsPerPage) + currentPageInChapter + 1;
|
||||||
|
|
||||||
pageInfo.innerText = `${currentPageInChapter + 1} / ${totalPagesInChapter} (전체 ${estimatedCurrentPage} / ${estimatedTotalPages})`;
|
pageInfo.innerText = `${currentPageInChapter + 1} / ${totalPagesInChapter} (전체 ${estimatedCurrentPage} / ${estimatedTotalPages})`;
|
||||||
|
|
||||||
// 1. 현재 날짜와 시간 구하기 (예: 2026-08-03 16:51)
|
// 1. 현재 날짜와 시간 구하기
|
||||||
const now = new Date();
|
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')}`;
|
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)) || {};
|
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({
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify({
|
||||||
chapter: currentChapterIndex,
|
chapter: currentChapterIndex,
|
||||||
page: currentPageInChapter,
|
page: currentPageInChapter,
|
||||||
lastRead: timeString, // 갱신된 시간
|
lastRead: timeString, // 갱신된 시간
|
||||||
memo: existingData.memo || '' // 기존 메모 유지 (없으면 빈칸)
|
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);
|
setTimeout(() => { isAnimating = false; }, 250);
|
||||||
}
|
}
|
||||||
@@ -1349,6 +1444,144 @@
|
|||||||
|
|
||||||
return fixed;
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -114,6 +114,7 @@ class WebReaderActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun setupWebView() {
|
private fun setupWebView() {
|
||||||
webView.settings.apply {
|
webView.settings.apply {
|
||||||
|
cacheMode = WebSettings.LOAD_NO_CACHE
|
||||||
javaScriptEnabled = true
|
javaScriptEnabled = true
|
||||||
domStorageEnabled = true
|
domStorageEnabled = true
|
||||||
allowFileAccess = true
|
allowFileAccess = true
|
||||||
|
|||||||
Reference in New Issue
Block a user