From 218638b53981e29e854b9d279517151e39c5fbe2 Mon Sep 17 00:00:00 2001 From: Gyubin Han Date: Sun, 9 Nov 2025 15:18:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B8=B0=EB=B3=B8=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 58 +++++++++++++ README.md | 39 +++++++++ icons/icon128.png | Bin 0 -> 517 bytes icons/icon16.png | Bin 0 -> 174 bytes icons/icon48.png | Bin 0 -> 242 bytes manifest.json | 17 ++++ newtab.html | 26 ++++++ script.js | 210 ++++++++++++++++++++++++++++++++++++++++++++++ style.css | 156 ++++++++++++++++++++++++++++++++++ 9 files changed, 506 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 icons/icon128.png create mode 100644 icons/icon16.png create mode 100644 icons/icon48.png create mode 100644 manifest.json create mode 100644 newtab.html create mode 100644 script.js create mode 100644 style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68e589b --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.log/ + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# dotenv environment variables file +.env +.env.local +.env.*.local + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# IDE - IntelliJ / WebStorm +.idea/ +*.iml +*.ipr +*.iws + +# OS files +.DS_Store +Thumbs.db +desktop.ini + +# Build output +dist/ +build/ +*.crx +*.pem +*.zip + +# Temporary files +*.tmp +*.temp +.cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb84115 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Quick Links - Chrome Extension + +새 탭에서 자주 사용하는 링크를 빠르게 저장하고 접근할 수 있는 크롬 확장 프로그램입니다. + +## 기능 + +- 새 탭 열 때 저장된 링크 목록 표시 +- 링크 추가/삭제 기능 +- 클릭으로 빠른 이동 +- Chrome 계정에 자동 동기화 + +## 설치 방법 + +1. 크롬 브라우저에서 `chrome://extensions/` 접속 +2. 우측 상단의 "개발자 모드" 활성화 +3. "압축해제된 확장 프로그램을 로드합니다" 클릭 +4. 이 폴더 선택 + +## 아이콘 추가 (선택사항) + +`icons` 폴더에 다음 크기의 아이콘을 추가하세요: +- icon16.png (16x16) +- icon48.png (48x48) +- icon128.png (128x128) + +아이콘이 없어도 확장 프로그램은 정상 작동합니다. + +## 사용 방법 + +1. 새 탭을 열면 링크 목록이 표시됩니다 +2. 상단 입력창에 제목과 URL을 입력하고 "추가" 버튼 클릭 +3. 카드를 클릭하면 해당 사이트로 이동 +4. X 버튼을 클릭하면 링크 삭제 + +## 기술 스택 + +- HTML/CSS/JavaScript +- Chrome Extension API (Manifest V3) +- Chrome Storage API diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..760c03d57149947ddd97cdcbb5a28640dfbfdab3 GIT binary patch literal 517 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSjKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85sBufiR<}hF1en!z@o1$B>G+w|5R^9(E9LxOms(;j24y?#_vA zopgXL-pst&@xQHvNqMc9O3Skywta$SG5XoJXMcZw>)gxi*iIpbISh|j1k@WW7>{r& z_%ReRbqLc_C{ND*n%@LrG@Ex==JkK6Zg^Z3dHdcT`|D+Xe&o6Dp3H$eJoC5iR$pJp zgyFr*zyJO{{9hT}WT2jRk(1Bgs%QUTj?>x)U&_`$kH^r96p)$8HO${?ReAVLe0+gX O$KdJe=d#Wzp$P!bp{I-h literal 0 HcmV?d00001 diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..a3a12402cfb9516d3e699f64d13f18710aa69068 GIT binary patch literal 174 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85sBufiR<}hF1en(Am?)F~q_@IVIxDBYS4K5B6bm8Vf&qYw!yj zIA(4-xPmdnpy8vvfWys{#)nh+Bo>-zHcSi>|Ko1fFz3XAb=)Tyd3YF}uM^VId=V=H PG>*a3)z4*}Q$iB}z%nv+ literal 0 HcmV?d00001 diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..de7905a6a83c9822de7769d0a2e32b85b103e1c4 GIT binary patch literal 242 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC#^NA%Cx&(BWL^R}Ea{HEjtmSN z`?>!lvI6;>1s;*b3=DjSK$uZf!>a)(*zW1#7?R=q_QpZpW&<9Fz}?;@R?`-ro~r4j zeP*Wj#qw4EmvG4c^b%1ty7s+8y7y^hu*<$}v+AA)p3jtYFm6a=ILk1L(S$jJ?E=pN zNr%~X3dh~g27ltX_($IbB718;d&=6pbKkxC1@bO6ynlXs;~mCZt_OC8-mT#Nn#gcC fdka5vgrIfoZ}A)lQ_YV+Coy=s`njxgN@xNAb&XXG literal 0 HcmV?d00001 diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..188a88c --- /dev/null +++ b/manifest.json @@ -0,0 +1,17 @@ +{ + "manifest_version": 3, + "name": "New Tab Quick Links", + "version": "1.0", + "description": "새 탭에서 자주 사용하는 링크 목록을 관리하고 빠르게 접근할 수 있습니다", + "chrome_url_overrides": { + "newtab": "newtab.html" + }, + "permissions": [ + "storage" + ], + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/newtab.html b/newtab.html new file mode 100644 index 0000000..20fcd58 --- /dev/null +++ b/newtab.html @@ -0,0 +1,26 @@ + + + + + + 새 탭 + + + +
+

Quick Links

+ + + + +
+ + + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..4527cc3 --- /dev/null +++ b/script.js @@ -0,0 +1,210 @@ +// DOM 요소 +const linkTitleInput = document.getElementById('linkTitle'); +const linkUrlInput = document.getElementById('linkUrl'); +const addBtn = document.getElementById('addBtn'); +const linksContainer = document.getElementById('linksContainer'); + +// 링크 목록을 저장하고 불러오기 +let links = []; + +// 초기 로드 +loadLinks(); + +// 이벤트 리스너 +addBtn.addEventListener('click', addLink); +linkTitleInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') addLink(); +}); +linkUrlInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') addLink(); +}); + +// 링크 추가 함수 +function addLink() { + const title = linkTitleInput.value.trim(); + const url = linkUrlInput.value.trim(); + + if (!title || !url) { + alert('제목과 URL을 모두 입력해주세요.'); + return; + } + + // URL 형식 검증 (간단한 http/https 체크) + if (!url.startsWith('http://') && !url.startsWith('https://')) { + alert('올바른 URL 형식을 입력해주세요. (\'http://\' 또는 \'https://\'로 시작)'); + return; + } + + const newLink = { + id: Date.now(), + title: title, + url: url + }; + + links.push(newLink); + saveLinks(); + renderLinks(); + + // 입력 필드 초기화 + linkTitleInput.value = ''; + linkUrlInput.value = ''; + linkTitleInput.focus(); +} + +// 링크 삭제 함수 +function deleteLink(id) { + if (confirm('이 링크를 삭제하시겠습니까?')) { + links = links.filter(link => link.id !== id); + saveLinks(); + renderLinks(); + } +} + +// 링크로 이동 함수 +function navigateToLink(url) { + window.location.href = url; +} + +// 링크 렌더링 함수 +function renderLinks() { + linksContainer.innerHTML = ''; + + if (links.length === 0) { + linksContainer.innerHTML = ` +
+

저장된 링크가 없습니다

+ 위에서 자주 방문하는 사이트를 추가해보세요 +
+ `; + return; + } + + links.forEach((link, index) => { + const linkCard = document.createElement('div'); + linkCard.className = 'link-card'; + linkCard.draggable = true; + linkCard.dataset.index = index; + + linkCard.innerHTML = ` + +

${escapeHtml(link.title)}

+

${escapeHtml(link.url)}

+ `; + + // 삭제 버튼 이벤트 리스너 + const deleteBtn = linkCard.querySelector('.delete-btn'); + deleteBtn.addEventListener('click', (e) => { + e.stopPropagation(); // 카드 클릭 이벤트 전파 방지 + deleteLink(link.id); + }); + + // 카드 클릭 시 링크로 이동 (삭제 버튼 제외) + linkCard.addEventListener('click', (e) => { + if (!e.target.classList.contains('delete-btn')) { + navigateToLink(link.url); + } + }); + + // 드래그 앤 드롭 이벤트 + linkCard.addEventListener('dragstart', handleDragStart); + linkCard.addEventListener('dragover', handleDragOver); + linkCard.addEventListener('drop', handleDrop); + linkCard.addEventListener('dragenter', handleDragEnter); + linkCard.addEventListener('dragleave', handleDragLeave); + linkCard.addEventListener('dragend', handleDragEnd); + + linksContainer.appendChild(linkCard); + }); +} + +// Chrome Storage에 저장 +function saveLinks() { + chrome.storage.sync.set({ links: links }, () => { + console.log('Links saved'); + }); +} + +// Chrome Storage에서 불러오기 +function loadLinks() { + chrome.storage.sync.get(['links'], (result) => { + if (result.links) { + links = result.links; + } else { + // 기본 링크 예시 (선택사항) + links = [ + { id: 1, title: 'Google', url: 'https://www.google.com' }, + { id: 2, title: 'YouTube', url: 'https://www.youtube.com' }, + { id: 3, title: 'GitHub', url: 'https://github.com' } + ]; + saveLinks(); + } + renderLinks(); + }); +} + +// XSS 방지를 위한 HTML 이스케이프 +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// 드래그 앤 드롭 관련 변수 +let draggedElement = null; + +// 드래그 시작 +function handleDragStart(e) { + draggedElement = this; + this.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; +} + +// 드래그 오버 +function handleDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + return false; +} + +// 드래그 진입 +function handleDragEnter(e) { + if (this !== draggedElement) { + this.classList.add('drag-over'); + } +} + +// 드래그 떠남 +function handleDragLeave(e) { + this.classList.remove('drag-over'); +} + +// 드롭 +function handleDrop(e) { + e.stopPropagation(); + e.preventDefault(); + + if (draggedElement !== this) { + const fromIndex = parseInt(draggedElement.dataset.index); + const toIndex = parseInt(this.dataset.index); + + // 배열에서 항목 이동 + const movedItem = links.splice(fromIndex, 1)[0]; + links.splice(toIndex, 0, movedItem); + + saveLinks(); + renderLinks(); + } + + this.classList.remove('drag-over'); + return false; +} + +// 드래그 종료 +function handleDragEnd(e) { + this.classList.remove('dragging'); + + // 모든 drag-over 클래스 제거 + document.querySelectorAll('.link-card').forEach(card => { + card.classList.remove('drag-over'); + }); +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..a42d46b --- /dev/null +++ b/style.css @@ -0,0 +1,156 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + display: flex; + justify-content: center; + align-items: center; + padding: 20px; +} + +.container { + width: 100%; + max-width: 800px; + background: white; + border-radius: 20px; + padding: 40px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +h1 { + text-align: center; + color: #333; + margin-bottom: 30px; + font-size: 2.5em; +} + +.add-link-section { + display: flex; + gap: 10px; + margin-bottom: 30px; + flex-wrap: wrap; +} + +.add-link-section input { + flex: 1; + min-width: 200px; + padding: 12px 16px; + border: 2px solid #e0e0e0; + border-radius: 8px; + font-size: 14px; + transition: border-color 0.3s; +} + +.add-link-section input:focus { + outline: none; + border-color: #667eea; +} + +.add-link-section button { + padding: 12px 30px; + background: #667eea; + color: white; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.3s; +} + +.add-link-section button:hover { + background: #5568d3; +} + +.links-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 15px; +} + +.link-card { + background: #f8f9fa; + padding: 20px; + border-radius: 12px; + border: 2px solid #e0e0e0; + transition: all 0.3s; + cursor: grab; + position: relative; +} + +.link-card:active { + cursor: grabbing; +} + +.link-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1); + border-color: #667eea; +} + +.link-card h3 { + color: #333; + margin-bottom: 8px; + font-size: 1.1em; + word-break: break-word; +} + +.link-card p { + color: #666; + font-size: 0.85em; + word-break: break-all; + margin-bottom: 10px; +} + +.delete-btn { + position: absolute; + top: 10px; + right: 10px; + background: #ff4757; + color: white; + border: none; + border-radius: 50%; + width: 28px; + height: 28px; + cursor: pointer; + font-size: 16px; + line-height: 1; + transition: background 0.3s; +} + +.delete-btn:hover { + background: #ff3838; +} + +.empty-state { + text-align: center; + padding: 60px 20px; + color: #999; +} + +.empty-state p { + font-size: 1.2em; + margin-bottom: 10px; +} + +.empty-state small { + font-size: 0.9em; +} + +/* 드래그 앤 드롭 스타일 */ +.link-card.dragging { + opacity: 0.5; + transform: scale(0.95); +} + +.link-card.drag-over { + border-color: #667eea; + background: #e8ecff; + transform: scale(1.05); +}