feat: 기본 기능 구현
This commit is contained in:
58
.gitignore
vendored
Normal file
58
.gitignore
vendored
Normal file
@@ -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/
|
||||||
39
README.md
Normal file
39
README.md
Normal file
@@ -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
|
||||||
BIN
icons/icon128.png
Normal file
BIN
icons/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 517 B |
BIN
icons/icon16.png
Normal file
BIN
icons/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 174 B |
BIN
icons/icon48.png
Normal file
BIN
icons/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 242 B |
17
manifest.json
Normal file
17
manifest.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
newtab.html
Normal file
26
newtab.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>새 탭</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Quick Links</h1>
|
||||||
|
|
||||||
|
<div class="add-link-section">
|
||||||
|
<input type="text" id="linkTitle" placeholder="링크 제목">
|
||||||
|
<input type="url" id="linkUrl" placeholder="URL (https://example.com)">
|
||||||
|
<button id="addBtn">추가</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="links-container" id="linksContainer">
|
||||||
|
<!-- 링크들이 여기에 동적으로 추가됩니다 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
210
script.js
Normal file
210
script.js
Normal file
@@ -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 = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>저장된 링크가 없습니다</p>
|
||||||
|
<small>위에서 자주 방문하는 사이트를 추가해보세요</small>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
links.forEach((link, index) => {
|
||||||
|
const linkCard = document.createElement('div');
|
||||||
|
linkCard.className = 'link-card';
|
||||||
|
linkCard.draggable = true;
|
||||||
|
linkCard.dataset.index = index;
|
||||||
|
|
||||||
|
linkCard.innerHTML = `
|
||||||
|
<button class="delete-btn">×</button>
|
||||||
|
<h3>${escapeHtml(link.title)}</h3>
|
||||||
|
<p>${escapeHtml(link.url)}</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 삭제 버튼 이벤트 리스너
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
156
style.css
Normal file
156
style.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user