commit 218638b53981e29e854b9d279517151e39c5fbe2 Author: Gyubin Han Date: Sun Nov 9 15:18:08 2025 +0900 feat: 기본 기능 구현 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 0000000..760c03d Binary files /dev/null and b/icons/icon128.png differ diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000..a3a1240 Binary files /dev/null and b/icons/icon16.png differ diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000..de7905a Binary files /dev/null and b/icons/icon48.png differ 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); +}