...
This commit is contained in:
@@ -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;">×</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 = '';
|
||||
|
||||
@@ -526,10 +561,17 @@
|
||||
const li = document.createElement('li');
|
||||
li.className = 'library-item';
|
||||
|
||||
// 1. 로컬 스토리지에서 진행도, 시간, 메모 데이터 불러오기
|
||||
// 1. 로컬 스토리지 데이터 불러오기
|
||||
const pKey = `webReader_progress_${book.id}`;
|
||||
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
|
||||
? `<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>`
|
||||
: '';
|
||||
|
||||
// 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');
|
||||
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 || '' // 기존 메모 유지 (없으면 빈칸)
|
||||
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>
|
||||
+249
-16
@@ -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;">×</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 = '';
|
||||
|
||||
@@ -526,10 +561,17 @@
|
||||
const li = document.createElement('li');
|
||||
li.className = 'library-item';
|
||||
|
||||
// 1. 로컬 스토리지에서 진행도, 시간, 메모 데이터 불러오기
|
||||
// 1. 로컬 스토리지 데이터 불러오기
|
||||
const pKey = `webReader_progress_${book.id}`;
|
||||
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
|
||||
? `<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>`
|
||||
: '';
|
||||
|
||||
// 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');
|
||||
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 || '' // 기존 메모 유지 (없으면 빈칸)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user