From d73a1dda56817929bd9568c597c36eb1481b62d7 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Tue, 18 Aug 2026 11:28:54 +0900 Subject: [PATCH] ... --- .../my_extension/Novel_Scraper/content.js | 152 +++ .../my_extension/Novel_Scraper/manifest.json | 29 + .../my_extension/Novel_Scraper/popup.html | 42 + .../my_extension/Novel_Scraper/popup.js | 267 ++++ .../assets/extensions/my_extension/bookget.js | 85 ++ .../extensions/my_extension/reader.html | 1073 ++++++++--------- app/src/main/assets/reader.html | 1073 ++++++++--------- 7 files changed, 1573 insertions(+), 1148 deletions(-) create mode 100644 app/src/main/assets/extensions/my_extension/Novel_Scraper/content.js create mode 100644 app/src/main/assets/extensions/my_extension/Novel_Scraper/manifest.json create mode 100644 app/src/main/assets/extensions/my_extension/Novel_Scraper/popup.html create mode 100644 app/src/main/assets/extensions/my_extension/Novel_Scraper/popup.js create mode 100644 app/src/main/assets/extensions/my_extension/bookget.js diff --git a/app/src/main/assets/extensions/my_extension/Novel_Scraper/content.js b/app/src/main/assets/extensions/my_extension/Novel_Scraper/content.js new file mode 100644 index 00000000..5031877a --- /dev/null +++ b/app/src/main/assets/extensions/my_extension/Novel_Scraper/content.js @@ -0,0 +1,152 @@ +// 페이지 로딩 완료 후 실행 +window.addEventListener('load', async () => { + // 1. URL 패턴 확인 + const match = window.location.pathname.match(/\/novel\/(\d+)\/(\d+)/); + if (!match) return; + + const bookId = match[1]; + const chapterId = match[2]; + console.log(`웹소설 스크래퍼: 책 ID [${bookId}] 감지됨.`); + + // ⏳ 2. 1~2초 동안 '페이지 다운(80%)' 스크롤 실행 + const initialScrollTime = Math.floor(Math.random() * (10000 - 1000 + 1)) + 8000; + console.log(`${(initialScrollTime / 1000).toFixed(1)}초 동안 큼직하게 훑어보며 대기합니다... 👀`); + await pageSkimScroll(initialScrollTime); + + // 3. 데이터 스크래핑 + const scrapedData = scrapePage(); + if (!scrapedData) return; + + // 4. 책 ID별 Storage 저장 + chrome.storage.local.get(['scrapedBooks'], async (result) => { + let scrapedBooks = result.scrapedBooks || {}; + + if (!scrapedBooks[bookId]) { + scrapedBooks[bookId] = { + id: 'book_' + bookId, + fileName: scrapedData.novelTitle, + chapters: [] + }; + } + + let bookData = scrapedBooks[bookId]; + + if (scrapedData.novelTitle !== '제목없음') { + bookData.fileName = scrapedData.novelTitle; + } + + // 중복 챕터 검사 + const isDuplicate = bookData.chapters.some(chap => chap.chapterId === chapterId); + if (isDuplicate) { + console.log(`⚠️ 이미 저장된 챕터입니다 (ID: ${chapterId}). 자동 진행을 중지합니다.`); + return; + } + + // 챕터 추가 + bookData.chapters.push({ + chapterId: chapterId, + title: scrapedData.chapterTitle, + content: scrapedData.content + }); + + // Storage에 저장 + scrapedBooks[bookId] = bookData; + chrome.storage.local.set({ + scrapedBooks: scrapedBooks, + currentBookId: bookId + }, async () => { + console.log(`✅ [${scrapedData.novelTitle} - ${scrapedData.chapterTitle}] 저장 완료!`); + + // ⏳ 5. 저장 완료 후 2~3초 랜덤 대기 + const waitDelay = Math.floor(Math.random() * (5000 - 2000 + 1)) + 4000; + console.log(`${(waitDelay / 1000).toFixed(1)}초 대기 후 다음 챕터로 이동합니다... ⏳`); + await new Promise(resolve => setTimeout(resolve, waitDelay)); + + // '다음화' 버튼 클릭 + const buttons = Array.from(document.querySelectorAll('a.btn.btn-black.btn-sm')); + const nextButton = buttons.find(btn => btn.innerText.trim().includes('다음화')); + + if (nextButton) { + console.log("👉 다음 화 버튼 클릭 진행"); + nextButton.click(); + } else { + console.log("⚠️ '다음화' 버튼을 찾을 수 없거나 마지막 화입니다."); + } + }); + }); +}); + +// ========================================== +// 🚀 화면 높이의 약 80%씩 큼직하게 스크롤하는 함수 +// ========================================== +function pageSkimScroll(duration) { + return new Promise((resolve) => { + let elapsed = 0; + + const scrollLoop = () => { + if (elapsed >= duration) { + resolve(); + return; + } + + // 400ms ~ 700ms 사이의 랜덤한 대기 시간 (큼직하게 내리기 때문에 간격을 조금 더 줌) + const stepTime = Math.floor(Math.random() * (1700 - 400 + 1)) + 1400; + + // 현재 브라우저 창 높이(Viewport)를 구함 + const screenHeight = window.innerHeight; + + // 75% ~ 85% 사이의 랜덤한 비율 설정 (평균 80%) + const scrollRatio = 0.75 + (Math.random() * 0.1); + const stepAmount = screenHeight * scrollRatio; + + // 부드럽게 스크롤 + window.scrollBy({ top: stepAmount, behavior: 'smooth' }); + + elapsed += stepTime; + setTimeout(scrollLoop, stepTime); + }; + + // 루프 시작 + scrollLoop(); + }); +} + +// ========================================== +// 스크래핑 헬퍼 함수 +// ========================================== +function scrapePage() { + const titleEl = document.querySelector('.page-title h2'); + let novelTitle = titleEl ? titleEl.innerText.trim() : '제목없음'; + + let chapterTitle = document.querySelector('.theme-novel-title'); + chapterTitle = chapterTitle ? chapterTitle.innerText.trim() : '1화'; + + let novelContent = ''; + const contentHost = document.querySelector('.theme-novel-content'); + + if (contentHost) { + if (contentHost.shadowRoot) { + const pTags = contentHost.shadowRoot.querySelectorAll('p'); + const textArray = Array.from(pTags).map(p => p.innerText.trim()); + novelContent = textArray.join('\n\n'); + } else { + const template = contentHost.querySelector('template[shadowrootmode="open"]'); + if (template && template.content) { + const pTags = template.content.querySelectorAll('p'); + const textArray = Array.from(pTags).map(p => p.textContent.trim()); + novelContent = textArray.join('\n\n'); + } + } + } + + if (!novelContent) { + console.warn('본문을 추출할 수 없습니다.'); + return null; + } + + return { + novelTitle: novelTitle, + chapterTitle: chapterTitle, + content: novelContent + }; +} \ No newline at end of file diff --git a/app/src/main/assets/extensions/my_extension/Novel_Scraper/manifest.json b/app/src/main/assets/extensions/my_extension/Novel_Scraper/manifest.json new file mode 100644 index 00000000..d4dc5134 --- /dev/null +++ b/app/src/main/assets/extensions/my_extension/Novel_Scraper/manifest.json @@ -0,0 +1,29 @@ +{ + "manifest_version": 3, + "name": "웹소설 스크래퍼 (나만의 서재용)", + "version": "1.2", + "description": "웹소설 챕터를 수집하여 서재 리더기용 JSON으로 다운로드합니다.", + "action": { + "default_popup": "popup.html" + }, + "permissions": [ + "activeTab", + "scripting", + "storage", + "downloads", + "unlimitedStorage" + ], + "host_permissions": [ + "https://api.telegram.org/*" + ], + "content_scripts": [ + { + "matches": [ + "*://newtoki1.org/novel/*", + "*://*.newtoki1.org/novel/*" + ], + "js": ["content.js"], + "run_at": "document_end" + } + ] +} \ No newline at end of file diff --git a/app/src/main/assets/extensions/my_extension/Novel_Scraper/popup.html b/app/src/main/assets/extensions/my_extension/Novel_Scraper/popup.html new file mode 100644 index 00000000..e7df4a13 --- /dev/null +++ b/app/src/main/assets/extensions/my_extension/Novel_Scraper/popup.html @@ -0,0 +1,42 @@ + + + + + + + +

📖 소설 스크래퍼

+ +
+ + + + + +
+ + + + + + +
대기 중...
+ + + + \ No newline at end of file diff --git a/app/src/main/assets/extensions/my_extension/Novel_Scraper/popup.js b/app/src/main/assets/extensions/my_extension/Novel_Scraper/popup.js new file mode 100644 index 00000000..14d02ad7 --- /dev/null +++ b/app/src/main/assets/extensions/my_extension/Novel_Scraper/popup.js @@ -0,0 +1,267 @@ +document.addEventListener('DOMContentLoaded', () => { + updateStatus(); + loadTelegramConfig(); + + // 1. [설정 저장] 버튼 + document.getElementById('btn-save-config').addEventListener('click', saveTelegramConfig); + + // 2. [저장하기] 버튼 + // 2. [저장하기] 버튼 + document.getElementById('btn-save').addEventListener('click', async () => { + let [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + + chrome.scripting.executeScript({ + target: { tabId: tab.id }, + function: scrapePage + }, (results) => { + if (results && results[0] && results[0].result) { + const data = results[0].result; + const match = tab.url ? tab.url.match(/\/novel\/(\d+)\/(\d+)/) : null; + const bookId = match ? match[1] : 'manual_' + Date.now(); + const chapterId = match ? match[2] : 'manual_chap_' + Date.now(); // 챕터 ID 추가 + + // chapterId를 같이 넘겨줍니다. + saveToExtensionStorage(data, bookId, chapterId); + } + }); + }); + + // 3. [다운로드] 버튼 + document.getElementById('btn-download').addEventListener('click', downloadJson); + + // 4. [텔레그램 전송] 버튼 + document.getElementById('btn-telegram').addEventListener('click', sendToTelegram); + + // 5. [초기화] 버튼 + document.getElementById('btn-clear').addEventListener('click', clearStorage); +}); + +// 텔레그램 설정 불러오기 +function loadTelegramConfig() { + chrome.storage.local.get(['tgToken', 'tgChatId'], (result) => { + if (result.tgToken) document.getElementById('tg-token').value = result.tgToken; + if (result.tgChatId) document.getElementById('tg-chatid').value = result.tgChatId; + }); +} + +// 텔레그램 설정 저장 +function saveTelegramConfig() { + const token = document.getElementById('tg-token').value.trim(); + const chatId = document.getElementById('tg-chatid').value.trim(); + + chrome.storage.local.set({ tgToken: token, tgChatId: chatId }, () => { + alert("✅ 텔레그램 설정이 저장 되었습니다!"); + }); +} + +// 🚀 텔레그램 API로 JSON 파일 전송 함수 +function sendToTelegram() { + chrome.storage.local.get(['scrapedBooks', 'currentBookId', 'tgToken', 'tgChatId'], async (result) => { + const token = result.tgToken; + const chatId = result.tgChatId; + + if (!token || !chatId) { + alert("⚠️ 먼저 텔레그램 Bot Token과 Chat ID를 설정하고 저장해주세요!"); + return; + } + + const books = result.scrapedBooks || {}; + const currentBookId = result.currentBookId; + const bookData = currentBookId ? books[currentBookId] : null; + + if (!bookData || bookData.chapters.length === 0) { + alert("전송할 데이터가 없습니다."); + return; + } + + // 리더기 포맷 페이로드 생성 + const payload = { + type: 'single_book', + book: bookData, + progress: { chapter: 0, page: 0, tags: [], memo: "스크래퍼 확장프로그램으로 수집됨", isFavorite: false, isCompleted: false } + }; + + const jsonString = JSON.stringify(payload, null, 2); + const safeFileName = bookData.fileName.replace(/[\\/:*?"<>|]/g, '_'); + + // JSON 문자열을 Blob 파일 객체로 변환 + const blob = new Blob([jsonString], { type: 'application/json' }); + + // Multipart FormData 생성 (텔레그램 API 전송용) + const formData = new FormData(); + formData.append('chat_id', chatId); + formData.append('document', blob, `book_${safeFileName}.json`); + formData.append('caption', `📖 [${bookData.fileName}]\n총 ${bookData.chapters.length}화 수집 완료 JSON 파일입니다.`); + + // 버튼 상태 변경 + const btnTg = document.getElementById('btn-telegram'); + btnTg.innerText = "⏳ 전송 중..."; + btnTg.disabled = true; + + try { + // Telegram sendDocument API 호출 + const response = await fetch(`https://api.telegram.org/bot${token}/sendDocument`, { + method: 'POST', + body: formData + }); + + const resData = await response.json(); + + if (resData.ok) { + alert(`🚀 [${bookData.fileName}] 텔레그램으로 전송 성공!`); + } else { + alert(`❌ 전송 실패: ${resData.description}`); + } + } catch (error) { + console.error(error); + alert("❌ 네트워크 오류가 발생했습니다."); + } finally { + btnTg.innerText = "🚀 텔레그램으로 JSON 전송"; + btnTg.disabled = false; + } + }); +} + +// 스크래핑 함수 (수동 저장용) +function scrapePage() { + const titleEl = document.querySelector('.page-title h2'); + let novelTitle = titleEl ? titleEl.innerText.trim() : '제목없음'; + + let chapterTitle = document.querySelector('.theme-novel-title'); + chapterTitle = chapterTitle ? chapterTitle.innerText.trim() : '1화'; + + let novelContent = ''; + const contentHost = document.querySelector('.theme-novel-content'); + + if (contentHost) { + if (contentHost.shadowRoot) { + const pTags = contentHost.shadowRoot.querySelectorAll('p'); + const textArray = Array.from(pTags).map(p => p.innerText.trim()); + novelContent = textArray.join('\n\n'); + } else { + const template = contentHost.querySelector('template[shadowrootmode="open"]'); + if (template && template.content) { + const pTags = template.content.querySelectorAll('p'); + const textArray = Array.from(pTags).map(p => p.textContent.trim()); + novelContent = textArray.join('\n\n'); + } + } + } + + if (!novelContent) { + alert('본문을 추출할 수 없습니다.'); + return null; + } + + return { + novelTitle: novelTitle, + chapterTitle: chapterTitle, + content: novelContent + }; +} + +// 수동 저장 함수 +function saveToExtensionStorage(scrapedData, bookId) { + chrome.storage.local.get(['scrapedBooks'], (result) => { + let scrapedBooks = result.scrapedBooks || {}; + + if (!scrapedBooks[bookId]) { + scrapedBooks[bookId] = { + id: 'book_' + bookId, + fileName: scrapedData.novelTitle, + chapters: [] + }; + } + + let bookData = scrapedBooks[bookId]; + + // 챕터 ID 기준으로 중복 검사 + const isDuplicate = bookData.chapters.some(chap => chap.chapterId === chapterId); + if (isDuplicate) { + alert('⚠️ 이미 저장된 챕터입니다!'); + return; + } + + bookData.chapters.push({ + chapterId: chapterId, + title: scrapedData.chapterTitle, + content: scrapedData.content + }); + scrapedBooks[bookId] = bookData; + + chrome.storage.local.set({ scrapedBooks: scrapedBooks, currentBookId: bookId }, () => { + updateStatus(); + alert(`✅ [${scrapedData.chapterTitle}] 저장 완료!`); + }); + }); +} + +// 다운로드 함수 +function downloadJson() { + chrome.storage.local.get(['scrapedBooks', 'currentBookId'], (result) => { + const books = result.scrapedBooks || {}; + const currentBookId = result.currentBookId; + const bookData = currentBookId ? books[currentBookId] : null; + + if (!bookData || bookData.chapters.length === 0) { + alert("다운로드할 데이터가 없습니다."); + return; + } + + const payload = { + type: 'single_book', + book: bookData, + progress: { chapter: 0, page: 0, tags: [], memo: "스크래퍼 확장프로그램으로 수집됨", isFavorite: false, isCompleted: false } + }; + + const jsonString = JSON.stringify(payload, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const safeFileName = bookData.fileName.replace(/[\\/:*?"<>|]/g, '_'); + + chrome.downloads.download({ + url: url, + filename: `book_${safeFileName}.json`, + saveAs: true + }); + }); +} + +// 초기화 함수 +function clearStorage() { + chrome.storage.local.get(['scrapedBooks', 'currentBookId'], (result) => { + const books = result.scrapedBooks || {}; + const currentBookId = result.currentBookId; + + if (!currentBookId || !books[currentBookId]) { + alert("삭제할 수집 데이터가 없습니다."); + return; + } + + const bookTitle = books[currentBookId].fileName; + if(confirm(`[${bookTitle}] 수집 데이터를 삭제하시겠습니까?`)) { + delete books[currentBookId]; + chrome.storage.local.set({ scrapedBooks: books, currentBookId: null }, () => { + updateStatus(); + }); + } + }); +} + +// 상태창 업데이트 +function updateStatus() { + chrome.storage.local.get(['scrapedBooks', 'currentBookId'], (result) => { + const books = result.scrapedBooks || {}; + const currentBookId = result.currentBookId; + const book = currentBookId ? books[currentBookId] : null; + + const count = book ? book.chapters.length : 0; + const title = book ? book.fileName : '없음'; + + document.getElementById('status').innerHTML = ` + 현재 선택 소설: ${title}
+ 모인 챕터: ${count}화 + `; + }); +} \ No newline at end of file diff --git a/app/src/main/assets/extensions/my_extension/bookget.js b/app/src/main/assets/extensions/my_extension/bookget.js new file mode 100644 index 00000000..79f128cf --- /dev/null +++ b/app/src/main/assets/extensions/my_extension/bookget.js @@ -0,0 +1,85 @@ +// ========================================== +// 1. 챕터를 로컬 스토리지에 누적 저장하는 함수 +// ========================================== +function saveChapterToLocal(bookTitle, chapterTitle, chapterContent) { + const STORAGE_KEY = 'webReader_scraper_temp'; + let bookData = JSON.parse(localStorage.getItem(STORAGE_KEY)); + + // 기존 데이터가 없거나, 다른 책을 스크랩하기 시작한 경우 초기화 + if (!bookData || bookData.fileName !== bookTitle) { + bookData = { + id: 'book_scraped_' + Date.now(), // 고유 ID 생성 + fileName: bookTitle, + chapters: [] + }; + console.log(`새로운 책 [${bookTitle}] 스크랩을 시작합니다.`); + } + + // 중복 저장 방지 로직 (동일한 챕터 제목이 있는지 검사) + const isDuplicate = bookData.chapters.some(chap => chap.title === chapterTitle); + if (isDuplicate) { + console.warn(`⚠️ 이미 저장된 챕터입니다: ${chapterTitle}`); + return false; + } + + // 챕터 데이터 추가 + bookData.chapters.push({ + title: chapterTitle, + content: chapterContent + }); + + // 변경된 데이터를 다시 스토리지에 저장 + localStorage.setItem(STORAGE_KEY, JSON.stringify(bookData)); + console.log(`✅ [${bookTitle}] ${chapterTitle} 저장 완료! (현재 총 ${bookData.chapters.length}화)`); + return true; +} + +// ========================================== +// 2. 누적된 데이터를 리더기 호환 JSON으로 다운로드하는 함수 +// ========================================== +function downloadBookAsJson() { + const STORAGE_KEY = 'webReader_scraper_temp'; + const bookData = JSON.parse(localStorage.getItem(STORAGE_KEY)); + + if (!bookData || !bookData.chapters || bookData.chapters.length === 0) { + alert("다운로드할 챕터 데이터가 없습니다. 먼저 챕터를 저장해주세요."); + return; + } + + // ★ 리더기의 '개별 책 복원' 형식과 완벽히 일치하도록 페이로드 구성 + const payload = { + type: 'single_book', + book: bookData, + progress: { + chapter: 0, + page: 0, + tags: [], // 리더기에서 불러올 때 재분석 기능으로 태그를 달 수 있습니다. + memo: "스크랩을 통해 추가된 소설입니다.", + isFavorite: false, + isCompleted: false + } + }; + + // JSON 파일로 변환 및 다운로드 처리 + const jsonString = JSON.stringify(payload, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + + // 파일명에 사용할 수 없는 특수문자 제거 + const safeFileName = bookData.fileName.replace(/[\\/:*?"<>|]/g, '_'); + a.download = `book_${safeFileName}.json`; + + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + // 다운로드 후 임시 데이터 정리 + if (confirm("파일 다운로드가 완료되었습니다.\n다음 스크랩을 위해 임시 저장된 데이터를 초기화할까요?")) { + localStorage.removeItem(STORAGE_KEY); + console.log("임시 데이터가 초기화되었습니다."); + } +} \ No newline at end of file diff --git a/app/src/main/assets/extensions/my_extension/reader.html b/app/src/main/assets/extensions/my_extension/reader.html index 621205fb..3d3ef1a0 100644 --- a/app/src/main/assets/extensions/my_extension/reader.html +++ b/app/src/main/assets/extensions/my_extension/reader.html @@ -166,7 +166,6 @@ input[type="file"] { margin-top: 15px; cursor: pointer; color: #ccc; } /* 서재 리스트 UI */ - /* 서재 팝업창 크기 및 여백 조정 */ .library-box { width: 90%; max-width: 480px; @@ -176,37 +175,36 @@ padding: 16px; } - /* 1열 리스트에서 -> 2열 카드 그리드 레이아웃으로 변경 */ .library-list { list-style: none; padding: 10px 2px; margin: 0; overflow-y: auto; + overflow-x: hidden; /* 가로 스크롤 방지 */ flex: 1; text-align: left; border-top: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color); - - /* 그리드 핵심 설정 */ - display: grid; - grid-template-columns: repeat(1, 1fr); /* 모바일 2열 배치 (화면에 약 6개 노출) */ - gap: 10px; /* 카드 사이 간격 */ + /* 💡 Grid 대신 Flex Column으로 변경하여 세로 스크롤에 최적화 */ + display: flex; + flex-direction: column; + gap: 12px; } - /* 카드 아이템 스타일 설정 */ .library-item { background: var(--modal-content-bg); border: 1px solid var(--border-color); border-radius: 10px; - padding: 12px; + padding: 14px; cursor: pointer; color: var(--text-color); display: flex; flex-direction: column; justify-content: space-between; box-sizing: border-box; - min-height: 160px; /* 140px -> 160px로 여유 제공 */ - height: auto; /* 내용이 많아지면 자동으로 카드 높이 늘어남 */ + width: 100%; + flex-shrink: 0; /* 💡 핵심: 화면이 좁아도 세로로 절대 찌그러지지 않게 강제 방어 */ + overflow: hidden; transition: transform 0.15s, background 0.15s; } @@ -215,12 +213,10 @@ } .library-item:active { - transform: scale(0.97); /* 터치 시 눌리는 효과 */ + transform: scale(0.97); } .btn-go-new { margin-top: 15px; padding: 12px; background: var(--accent-color); color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 15px; font-weight: bold; } - - /* 로딩 스피너 */ #loading-overlay { display: none; } .spinner { margin: 20px auto; width: 40px; height: 40px; border: 4px solid #444; border-top: 4px solid var(--accent-color); border-radius: 50%; animation: spin 1s linear infinite; } @@ -256,14 +252,28 @@ - - \ No newline at end of file diff --git a/app/src/main/assets/reader.html b/app/src/main/assets/reader.html index 6e5486f1..4b7da361 100644 --- a/app/src/main/assets/reader.html +++ b/app/src/main/assets/reader.html @@ -166,7 +166,6 @@ input[type="file"] { margin-top: 15px; cursor: pointer; color: #ccc; } /* 서재 리스트 UI */ - /* 서재 팝업창 크기 및 여백 조정 */ .library-box { width: 90%; max-width: 480px; @@ -176,37 +175,36 @@ padding: 16px; } - /* 1열 리스트에서 -> 2열 카드 그리드 레이아웃으로 변경 */ .library-list { list-style: none; padding: 10px 2px; margin: 0; overflow-y: auto; + overflow-x: hidden; /* 가로 스크롤 방지 */ flex: 1; text-align: left; border-top: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color); - - /* 그리드 핵심 설정 */ - display: grid; - grid-template-columns: repeat(1, 1fr); /* 모바일 2열 배치 (화면에 약 6개 노출) */ - gap: 10px; /* 카드 사이 간격 */ + /* 💡 Grid 대신 Flex Column으로 변경하여 세로 스크롤에 최적화 */ + display: flex; + flex-direction: column; + gap: 12px; } - /* 카드 아이템 스타일 설정 */ .library-item { background: var(--modal-content-bg); border: 1px solid var(--border-color); border-radius: 10px; - padding: 12px; + padding: 14px; cursor: pointer; color: var(--text-color); display: flex; flex-direction: column; justify-content: space-between; box-sizing: border-box; - min-height: 160px; /* 140px -> 160px로 여유 제공 */ - height: auto; /* 내용이 많아지면 자동으로 카드 높이 늘어남 */ + width: 100%; + flex-shrink: 0; /* 💡 핵심: 화면이 좁아도 세로로 절대 찌그러지지 않게 강제 방어 */ + overflow: hidden; transition: transform 0.15s, background 0.15s; } @@ -215,12 +213,10 @@ } .library-item:active { - transform: scale(0.97); /* 터치 시 눌리는 효과 */ + transform: scale(0.97); } .btn-go-new { margin-top: 15px; padding: 12px; background: var(--accent-color); color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 15px; font-weight: bold; } - - /* 로딩 스피너 */ #loading-overlay { display: none; } .spinner { margin: 20px auto; width: 40px; height: 40px; border: 4px solid #444; border-top: 4px solid var(--accent-color); border-radius: 50%; animation: spin 1s linear infinite; } @@ -256,14 +252,28 @@ - - \ No newline at end of file