Last active
July 25, 2025 10:15
-
-
Save lunamoth/4e7c39e62891392533dbc3b3cb988d27 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
manifest.json | |
{ | |
"manifest_version": 3, | |
"name": "새 탭 노트", | |
"version": "12.0", | |
"description": "새 탭을 깔끔하고 생산적인 폴더형 노트 공간으로 바꿔보세요.", | |
"permissions": [ | |
"storage", | |
"downloads" | |
], | |
"chrome_url_overrides": { | |
"newtab": "newtab.html" | |
} | |
} | |
script.js | |
document.addEventListener('DOMContentLoaded', () => { | |
// --- 상수 정의 --- | |
const CONSTANTS = { | |
ITEM_TYPE: { FOLDER: 'folder', NOTE: 'note' }, | |
MODAL_TYPE: { PROMPT: 'prompt', CONFIRM: 'confirm' }, | |
TOAST_TYPE: { SUCCESS: 'success', ERROR: 'error' }, | |
LS_KEY: 'newTabNoteLastSession_v10.0', // 버전 업데이트 | |
ALL_NOTES_ID: 'all-notes-virtual-id', | |
CLASSES: { | |
DRAGGING: 'dragging', | |
DROP_TARGET: 'drop-target', | |
PINNED: 'pinned', | |
ACTIVE: 'active' | |
} | |
}; | |
// --- DOM 요소 캐싱 --- | |
const getEl = id => document.getElementById(id); | |
const folderList = getEl('folder-list'), noteList = getEl('note-list'); | |
const foldersPanel = getEl('folders-panel'); | |
const addFolderBtn = getEl('add-folder-btn'), addNoteBtn = getEl('add-note-btn'); | |
const notesPanelTitle = getEl('notes-panel-title'); | |
const searchInput = getEl('search-input'), clearSearchBtn = getEl('clear-search'); | |
const noteSortSelect = getEl('note-sort-select'); | |
const exportBtn = getEl('export-btn'), importBtn = getEl('import-btn'), importFileInput = getEl('import-file-input'); | |
const editorContainer = getEl('editor-container'), placeholderContainer = getEl('placeholder-container'); | |
const noteTitleInput = getEl('note-title-input'), noteContentTextarea = getEl('note-content-textarea'); | |
const editorFooter = getEl('editor-footer'), toastContainer = getEl('toast-container'); | |
const modal = getEl('modal'), modalTitle = getEl('modal-title'), modalMessage = getEl('modal-message'); | |
const modalForm = getEl('modal-form'), modalInput = getEl('modal-input'); | |
const modalConfirmBtn = getEl('modal-confirm-btn'), modalCancelBtn = getEl('modal-cancel-btn'); | |
const itemTemplate = getEl('item-template'); | |
// --- 상태 및 유틸리티 --- | |
let state = { folders: [], activeFolderId: null, activeNoteId: null, searchTerm: '', noteSortOrder: 'updatedAt_desc', noteMap: new Map() }; | |
let debounceTimer; | |
let draggedItemInfo = { id: null, type: null, sourceFolderId: null }; | |
let isSaving = false; | |
const formatDate = d => new Date(d).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' }); | |
const debounce = (fn, delay) => { clearTimeout(debounceTimer); debounceTimer = setTimeout(fn, delay); }; | |
const _buildNoteMap = () => { | |
state.noteMap.clear(); | |
for (const folder of state.folders) { | |
for (const note of folder.notes) { | |
state.noteMap.set(note.id, { note, folderId: folder.id }); | |
} | |
} | |
}; | |
// --- 상태 관리 --- | |
const setState = (newState, renderFlags = { all: true }) => { | |
Object.assign(state, newState); | |
if (renderFlags.all) renderAll(); | |
else { | |
if (renderFlags.folders) renderFolders(); | |
if (renderFlags.notes) renderNotes(); | |
if (renderFlags.editor) renderEditor(); | |
} | |
saveSession(); | |
}; | |
// --- 데이터 관리 --- | |
const saveData = async () => { try { await chrome.storage.local.set({ appState: { folders: state.folders } }); } catch (e) { console.error("Error saving state:", e); }}; | |
const saveSession = () => localStorage.setItem(CONSTANTS.LS_KEY, JSON.stringify({ f: state.activeFolderId, n: state.activeNoteId, s: state.noteSortOrder })); | |
const loadData = async () => { | |
try { | |
const result = await chrome.storage.local.get('appState'); | |
let initialState = { folders: [], activeFolderId: null, activeNoteId: null, searchTerm: '', noteSortOrder: 'updatedAt_desc', noteMap: new Map() }; | |
if (result.appState && result.appState.folders?.length > 0) { | |
initialState = { ...initialState, ...result.appState }; | |
// [개선] 데이터 구조 호환성 처리 (noteOrder 추가) | |
initialState.folders.forEach(f => { | |
if (!f.noteOrder) { | |
f.noteOrder = f.notes.map(n => n.id); | |
} | |
}); | |
const lastSession = JSON.parse(localStorage.getItem(CONSTANTS.LS_KEY)); | |
if (lastSession) { | |
initialState.activeFolderId = lastSession.f; | |
initialState.activeNoteId = lastSession.n; | |
initialState.noteSortOrder = lastSession.s || 'updatedAt_desc'; | |
} | |
if (!initialState.activeFolderId || (initialState.activeFolderId !== CONSTANTS.ALL_NOTES_ID && !initialState.folders.some(f => f.id === initialState.activeFolderId))) { | |
initialState.activeFolderId = CONSTANTS.ALL_NOTES_ID; | |
} | |
} else { | |
const now = Date.now(); | |
const fId = `folder-${now}`, nId = `note-${now + 1}`; | |
const newNote = { id: nId, title: "🎉 환영합니다!", content: "새 탭 노트에 오신 것을 환영합니다! 🚀", createdAt: now, updatedAt: now, isPinned: false }; | |
initialState = { ...initialState, folders: [{ id: fId, name: "🌟 첫 시작 폴더", notes: [newNote], noteOrder: [nId] }], activeFolderId: fId, activeNoteId: nId }; | |
await saveData(initialState); | |
} | |
state = initialState; | |
_buildNoteMap(); | |
noteSortSelect.value = state.noteSortOrder; | |
renderAll(); | |
} catch (e) { console.error("Error loading data:", e); } | |
}; | |
// --- UI 피드백 (Toast, Modal) --- | |
const showToast = (message, type = CONSTANTS.TOAST_TYPE.SUCCESS) => { | |
const toast = document.createElement('div'); | |
toast.className = `toast ${type}`; | |
toast.textContent = message; | |
toastContainer.appendChild(toast); | |
setTimeout(() => toast.remove(), 4000); | |
}; | |
const showModal = ({ type, title, message = '', placeholder = '', initialValue = '', confirmText = '확인', cancelText = '취소' }) => { | |
return new Promise(resolve => { | |
modalTitle.textContent = title; | |
modalMessage.textContent = message; | |
modalConfirmBtn.textContent = confirmText; | |
modalCancelBtn.textContent = cancelText; | |
modalInput.style.display = type === CONSTANTS.MODAL_TYPE.PROMPT ? 'block' : 'none'; | |
modalInput.value = initialValue; | |
modalInput.placeholder = placeholder; | |
modal.showModal(); | |
if (type === CONSTANTS.MODAL_TYPE.PROMPT) modalInput.focus(); | |
const handleClose = () => { | |
modal.removeEventListener('close', handleClose); | |
resolve(modal.returnValue === 'confirm' ? (type === CONSTANTS.MODAL_TYPE.PROMPT ? modalInput.value : true) : null); | |
}; | |
modal.addEventListener('close', handleClose); | |
}); | |
}; | |
// --- [개선] 정렬 로직 함수화 --- | |
const sortNotes = (notes, sortOrder, folderNoteOrder = []) => { | |
const sorted = [...notes]; // 원본 배열 수정을 피하기 위해 복사 | |
sorted.sort((a, b) => { | |
// 고정된 노트는 항상 최상단에 위치 | |
if (a.isPinned !== b.isPinned) return b.isPinned - a.isPinned; | |
// '수동 정렬' 모드 처리 | |
if (sortOrder === 'manual') { | |
const indexA = folderNoteOrder.indexOf(a.id); | |
const indexB = folderNoteOrder.indexOf(b.id); | |
// noteOrder 배열에 없는 경우(예: 신규 노트) 최상단으로 | |
if (indexA === -1 && indexB !== -1) return -1; | |
if (indexB === -1 && indexA !== -1) return 1; | |
return indexA - indexB; | |
} | |
// 다른 정렬 모드 | |
switch (sortOrder) { | |
case 'createdAt_desc': return b.createdAt - a.createdAt; | |
case 'title_asc': return (a.title || '').localeCompare(b.title || '', 'ko-KR'); | |
case 'title_desc': return (b.title || '').localeCompare(a.title || '', 'ko-KR'); | |
case 'updatedAt_desc': | |
default: | |
return b.updatedAt - a.updatedAt; | |
} | |
}); | |
return sorted; | |
}; | |
// --- 렌더링 (DOM Diffing 최적화 적용) --- | |
const createListItemElement = (item, type) => { | |
const fragment = itemTemplate.content.cloneNode(true); | |
const li = fragment.querySelector('.item-list-entry'); | |
const actionsDiv = fragment.querySelector('.item-actions'); | |
li.dataset.id = item.id; | |
if (type === CONSTANTS.ITEM_TYPE.NOTE) { | |
const pinBtn = document.createElement('button'); | |
pinBtn.className = `icon-button pin-btn`; | |
pinBtn.textContent = '📌'; | |
pinBtn.title = '노트 고정'; | |
actionsDiv.appendChild(pinBtn); | |
} | |
if (item.id !== CONSTANTS.ALL_NOTES_ID) { | |
const deleteBtn = document.createElement('button'); | |
deleteBtn.className = 'icon-button delete-item-btn'; | |
deleteBtn.textContent = '🗑️'; | |
deleteBtn.title = type === CONSTANTS.ITEM_TYPE.NOTE ? '노트 삭제' : '폴더 삭제'; | |
actionsDiv.appendChild(deleteBtn); | |
} else { | |
li.draggable = false; | |
} | |
updateListItemElement(li, item, type); | |
return li; | |
}; | |
const highlightText = (container, text, term) => { | |
container.innerHTML = ''; | |
if (!term) { | |
container.textContent = text; | |
return; | |
} | |
const regex = new RegExp(`(${term.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')})`, 'gi'); | |
const parts = text.split(regex); | |
parts.forEach(part => { | |
if (part.toLowerCase() === term.toLowerCase()) { | |
const mark = document.createElement('mark'); | |
mark.textContent = part; | |
container.appendChild(mark); | |
} else { | |
container.appendChild(document.createTextNode(part)); | |
} | |
}); | |
}; | |
const updateListItemElement = (li, item, type) => { | |
const nameSpan = li.querySelector('.item-name'); | |
const snippetDiv = li.querySelector('.item-snippet'); | |
const folderTag = li.querySelector('.item-folder-tag'); | |
li.classList.toggle(CONSTANTS.CLASSES.ACTIVE, item.id === (type === CONSTANTS.ITEM_TYPE.FOLDER ? state.activeFolderId : state.activeNoteId)); | |
if (type === CONSTANTS.ITEM_TYPE.FOLDER) { | |
highlightText(nameSpan, item.name, ''); | |
} else { | |
const pinBtn = li.querySelector('.pin-btn'); | |
const isPinned = !!item.isPinned; | |
li.classList.toggle(CONSTANTS.CLASSES.PINNED, isPinned); | |
if(pinBtn) pinBtn.classList.toggle(CONSTANTS.CLASSES.PINNED, isPinned); | |
const title = item.title || '📝 제목 없음'; | |
highlightText(nameSpan, title, state.searchTerm); | |
// [개선] 검색 시 내용에 일치하는 부분이 있으면 항상 스니펫을 보여주도록 수정 | |
if (state.searchTerm && item.content?.toLowerCase().includes(state.searchTerm.toLowerCase())) { | |
const content = item.content; | |
const term = state.searchTerm; | |
const index = content.toLowerCase().indexOf(term.toLowerCase()); | |
const start = Math.max(0, index - 20); | |
const end = Math.min(content.length, index + term.length + 30); | |
const snippet = (start > 0 ? '...' : '') + content.substring(start, end) + (end < content.length ? '...' : ''); | |
highlightText(snippetDiv, snippet, term); | |
snippetDiv.style.display = 'block'; | |
} else { | |
snippetDiv.style.display = 'none'; | |
} | |
if (state.activeFolderId === CONSTANTS.ALL_NOTES_ID) { | |
const { folder } = findItem(item.id, CONSTANTS.ITEM_TYPE.NOTE); | |
folderTag.textContent = folder?.name || ''; | |
folderTag.style.display = folder ? 'inline-block' : 'none'; | |
} else { | |
folderTag.style.display = 'none'; | |
} | |
} | |
}; | |
const renderList = (listElement, items, type) => { | |
const itemMap = new Map(items.map(item => [item.id, item])); | |
const existingElements = new Map(Array.from(listElement.children).filter(el => el.dataset.id).map(el => [el.dataset.id, el])); | |
existingElements.forEach((el, id) => { | |
if (!itemMap.has(id)) el.remove(); | |
}); | |
let lastElement = null; | |
items.forEach(item => { | |
let currentEl = existingElements.get(item.id); | |
if (currentEl) { | |
updateListItemElement(currentEl, item, type); | |
} else { | |
currentEl = createListItemElement(item, type); | |
} | |
if (lastElement) { | |
if (lastElement.nextElementSibling !== currentEl) { | |
lastElement.after(currentEl); | |
} | |
} else { | |
if (listElement.firstElementChild !== currentEl) { | |
listElement.prepend(currentEl); | |
} | |
} | |
lastElement = currentEl; | |
}); | |
const pinDividerId = 'pin-divider'; | |
listElement.querySelector(`#${pinDividerId}`)?.remove(); | |
if (type === CONSTANTS.ITEM_TYPE.NOTE) { | |
const firstUnpinnedIndex = items.findIndex(item => !item.isPinned); | |
if (firstUnpinnedIndex > 0 && firstUnpinnedIndex < items.length) { | |
const divider = document.createElement('li'); | |
divider.id = pinDividerId; | |
divider.className = 'pin-divider'; | |
const firstUnpinnedEl = listElement.querySelector(`[data-id="${items[firstUnpinnedIndex].id}"]`); | |
if(firstUnpinnedEl) { | |
listElement.insertBefore(divider, firstUnpinnedEl); | |
} | |
} | |
} | |
}; | |
const renderFolders = () => { | |
const allFolders = [{ id: CONSTANTS.ALL_NOTES_ID, name: '📚 모든 노트' }, ...state.folders]; | |
renderList(folderList, allFolders, CONSTANTS.ITEM_TYPE.FOLDER); | |
}; | |
const renderNotes = () => { | |
addNoteBtn.style.display = 'block'; | |
let notesToDisplay = []; | |
let folderName = ''; | |
let currentFolder = null; | |
if (state.activeFolderId === CONSTANTS.ALL_NOTES_ID) { | |
folderName = '모든 노트'; | |
notesToDisplay = state.folders.flatMap(f => f.notes); | |
addNoteBtn.style.display = 'none'; | |
} else { | |
const { item: activeFolder } = findItem(state.activeFolderId, CONSTANTS.ITEM_TYPE.FOLDER); | |
currentFolder = activeFolder; | |
if (!activeFolder) { | |
notesPanelTitle.textContent = '📝 노트'; | |
addNoteBtn.style.display = 'none'; | |
noteList.innerHTML = `<p style="padding:12px; color:var(--font-color-dim); font-size:14px; text-align:center;">👈 먼저 폴더를<br>선택해주세요.</p>`; | |
return; | |
} | |
folderName = activeFolder.name; | |
notesToDisplay = activeFolder.notes; | |
} | |
notesPanelTitle.textContent = `📝 ${folderName}`; | |
const filteredNotes = notesToDisplay.filter(n => | |
(n.title || '').toLowerCase().includes(state.searchTerm.toLowerCase()) || | |
(n.content || '').toLowerCase().includes(state.searchTerm.toLowerCase()) | |
); | |
// [개선] `sortNotes` 함수 사용 | |
const sortedNotes = sortNotes(filteredNotes, state.noteSortOrder, currentFolder?.noteOrder); | |
noteSortSelect.value = state.noteSortOrder; // UI와 상태 동기화 | |
if (sortedNotes.length === 0) { | |
let message; | |
if (state.searchTerm) { | |
message = '🤷♂️<br>검색 결과가 없어요.'; | |
} else if (state.activeFolderId !== CONSTANTS.ALL_NOTES_ID) { | |
message = `✨<br>첫 노트를 작성해보세요!<br><button class="placeholder-button">새 노트 추가</button>`; | |
noteList.innerHTML = `<div class="placeholder">${message}</div>`; | |
noteList.querySelector('.placeholder-button')?.addEventListener('click', handleAddNote); | |
return; | |
} else { | |
message = '텅... 🤷♂️<br>노트가 없어요.'; | |
} | |
noteList.innerHTML = `<div class="placeholder">${message}</div>`; | |
} else { | |
renderList(noteList, sortedNotes, CONSTANTS.ITEM_TYPE.NOTE); | |
} | |
}; | |
const renderEditor = () => { | |
const { item: activeNote } = findItem(state.activeNoteId, CONSTANTS.ITEM_TYPE.NOTE); | |
if (activeNote) { | |
editorContainer.style.display = 'flex'; | |
placeholderContainer.style.display = 'none'; | |
if (document.activeElement !== noteTitleInput) noteTitleInput.value = activeNote.title; | |
if (document.activeElement !== noteContentTextarea) noteContentTextarea.value = activeNote.content; | |
editorFooter.innerHTML = ''; | |
const wordCount = (activeNote.content || '').split(/\s+/).filter(Boolean).length; | |
const createSpan = (text) => { | |
const span = document.createElement('span'); | |
span.textContent = text; | |
return span; | |
}; | |
editorFooter.appendChild(createSpan(`단어: ${wordCount}`)); | |
editorFooter.appendChild(createSpan(`생성일: ${formatDate(activeNote.createdAt)}`)); | |
editorFooter.appendChild(createSpan(`수정일: ${formatDate(activeNote.updatedAt)}`)); | |
} else { | |
editorContainer.style.display = 'none'; | |
placeholderContainer.style.display = 'flex'; | |
} | |
}; | |
const renderAll = () => { renderFolders(); renderNotes(); renderEditor(); }; | |
// --- 아이템 찾기/수정 헬퍼 --- | |
const findItem = (id, type) => { | |
if (type === CONSTANTS.ITEM_TYPE.FOLDER) { | |
if (id === CONSTANTS.ALL_NOTES_ID) return { item: {id, name: '모든 노트', notes: [], noteOrder: []}, index: -1}; | |
const index = state.folders.findIndex(f => f.id === id); | |
return { item: state.folders[index], index }; | |
} | |
if (type === CONSTANTS.ITEM_TYPE.NOTE) { | |
const entry = state.noteMap.get(id); | |
if (entry) { | |
const { item: folder } = findItem(entry.folderId, CONSTANTS.ITEM_TYPE.FOLDER); | |
if (folder) { | |
const index = folder.notes.findIndex(n => n.id === id); | |
return { item: entry.note, index, folder }; | |
} | |
} | |
} | |
return { item: null, index: -1, folder: null }; | |
}; | |
// --- 이벤트 핸들러 --- | |
const handleAddFolder = async () => { | |
const name = await showModal({ type: CONSTANTS.MODAL_TYPE.PROMPT, title: '📁 새 폴더 만들기', placeholder: '폴더 이름을 입력하세요' }); | |
if (name && name.trim()) { | |
if (state.folders.some(f => f.name === name.trim())) { | |
showToast(`'${name.trim()}' 폴더는 이미 존재합니다.`, CONSTANTS.TOAST_TYPE.ERROR); | |
return; | |
} | |
const newFolder = { id: `folder-${Date.now()}`, name: name.trim(), notes: [], noteOrder: [] }; | |
state.folders.push(newFolder); | |
setState({ activeFolderId: newFolder.id, activeNoteId: null, searchTerm: '' }); | |
await saveData(); | |
} | |
}; | |
const handleAddNote = async () => { | |
if (!state.activeFolderId || state.activeFolderId === CONSTANTS.ALL_NOTES_ID) return; | |
const { item: activeFolder } = findItem(state.activeFolderId, CONSTANTS.ITEM_TYPE.FOLDER); | |
if (activeFolder) { | |
const now = Date.now(); | |
const newNote = { id: `note-${now}`, title: "📝 새 노트", content: "", createdAt: now, updatedAt: now, isPinned: false }; | |
activeFolder.notes.unshift(newNote); | |
activeFolder.noteOrder.unshift(newNote.id); // [개선] 수동 정렬 순서에 추가 | |
_buildNoteMap(); | |
setState({ activeNoteId: newNote.id, searchTerm: '' }, { notes: true, editor: true }); | |
await saveData(); | |
noteTitleInput.focus(); noteTitleInput.select(); | |
} | |
}; | |
const handleListClick = (e, type) => { | |
const li = e.target.closest('.item-list-entry'); | |
if (!li) return; | |
const id = li.dataset.id; | |
if (e.target.closest('.pin-btn')) { handlePinNote(id); return; } | |
if (e.target.closest('.delete-item-btn')) { handleDelete(id, type); return; } | |
if (type === CONSTANTS.ITEM_TYPE.FOLDER && state.activeFolderId !== id) { | |
setState({ activeFolderId: id, activeNoteId: null, searchTerm: '' }); | |
searchInput.value = ''; | |
const { item: folder } = findItem(id, CONSTANTS.ITEM_TYPE.FOLDER); | |
let notesInFolder = []; | |
if (id === CONSTANTS.ALL_NOTES_ID) { | |
notesInFolder = state.folders.flatMap(f => f.notes); | |
} else if (folder) { | |
notesInFolder = folder.notes; | |
} | |
const sortedNotes = sortNotes(notesInFolder, state.noteSortOrder, folder?.noteOrder); | |
setState({ activeNoteId: sortedNotes?.[0]?.id || null }, { notes: true, editor: true }); | |
} else if (type === CONSTANTS.ITEM_TYPE.NOTE && state.activeNoteId !== id) { | |
setState({ activeNoteId: id }, { notes: true, editor: true }); | |
} | |
}; | |
const handlePinNote = async (id) => { | |
const { item: note } = findItem(id, CONSTANTS.ITEM_TYPE.NOTE); | |
if (note) { | |
note.isPinned = !note.isPinned; | |
note.updatedAt = Date.now(); | |
await saveData(); | |
renderNotes(); | |
} | |
}; | |
const handleDelete = async (id, type) => { | |
const { item } = findItem(id, type); | |
if (!item) return; | |
let ok; | |
if (type === CONSTANTS.ITEM_TYPE.FOLDER) { | |
const message = `'${item.name}' 폴더와 안의 모든 노트(${item.notes.length}개)를 삭제합니다. 정말 삭제하시겠습니까?`; | |
ok = await showModal({ type: CONSTANTS.MODAL_TYPE.CONFIRM, title: '🗑️ 폴더 삭제', message, confirmText: '삭제' }); | |
if (ok) { | |
const wasActive = state.activeFolderId === id; | |
state.folders = state.folders.filter(f => f.id !== id); | |
_buildNoteMap(); | |
if (wasActive) { | |
setState({ activeFolderId: CONSTANTS.ALL_NOTES_ID, activeNoteId: null }); | |
} else { | |
setState({}, { folders: true }); | |
} | |
} | |
} else { // NOTE | |
ok = await showModal({ type: CONSTANTS.MODAL_TYPE.CONFIRM, title: '🗑️ 노트 삭제', message: `'${item.title}' 노트를 정말 삭제하시겠습니까?`, confirmText: '삭제' }); | |
if (ok) { | |
const { folder } = findItem(id, CONSTANTS.ITEM_TYPE.NOTE); | |
if (folder) { | |
const sortedNotes = sortNotes(folder.notes, state.noteSortOrder, folder.noteOrder); | |
const deletedNoteIndexInSorted = sortedNotes.findIndex(n => n.id === id); | |
const nextNote = sortedNotes[deletedNoteIndexInSorted + 1] || sortedNotes[deletedNoteIndexInSorted - 1] || null; | |
// 실제 데이터에서 삭제 | |
const indexInOriginal = folder.notes.findIndex(n => n.id === id); | |
if (indexInOriginal > -1) folder.notes.splice(indexInOriginal, 1); | |
// [개선] 수동 정렬 순서에서도 삭제 | |
const indexInOrder = folder.noteOrder.indexOf(id); | |
if (indexInOrder > -1) folder.noteOrder.splice(indexInOrder, 1); | |
_buildNoteMap(); | |
if (state.activeNoteId === id) { | |
setState({ activeNoteId: nextNote?.id || null }, { notes: true, editor: true }); | |
} else { | |
setState({}, { notes: true }); | |
} | |
} | |
} | |
} | |
if (ok) await saveData(); | |
}; | |
const handleRename = (e, type) => { | |
const nameSpan = e.target.closest('.item-name'); | |
if (!nameSpan) return; | |
const li = nameSpan.closest('.item-list-entry'), id = li.dataset.id; | |
if (id === CONSTANTS.ALL_NOTES_ID) return; | |
const { item, folder } = findItem(id, type); | |
const originalName = (type === CONSTANTS.ITEM_TYPE.FOLDER) ? item.name : item.title; | |
li.draggable = false; | |
nameSpan.contentEditable = true; | |
nameSpan.focus(); | |
document.execCommand('selectAll', false, null); | |
const endRename = async (newName) => { | |
nameSpan.contentEditable = false; | |
li.draggable = true; | |
nameSpan.removeEventListener('blur', onBlur); | |
nameSpan.removeEventListener('keydown', onKeydown); | |
newName = newName.trim(); | |
let isDuplicate = false; | |
if (newName && newName !== originalName) { | |
if (type === CONSTANTS.ITEM_TYPE.FOLDER) { | |
isDuplicate = state.folders.some(f => f.id !== id && f.name === newName); | |
} else { | |
isDuplicate = folder.notes.some(n => n.id !== id && n.title === newName); | |
} | |
} | |
if (isDuplicate) { | |
showToast(`'${newName}' 이름이 이미 존재합니다.`, CONSTANTS.TOAST_TYPE.ERROR); | |
highlightText(nameSpan, originalName, ''); | |
return; | |
} | |
if (newName && newName !== originalName) { | |
if (type === CONSTANTS.ITEM_TYPE.FOLDER) { | |
item.name = newName; | |
setState({}, { folders: true, notes: true }); | |
} else { | |
item.title = newName; | |
item.updatedAt = Date.now(); | |
setState({}, { notes: true, editor: true }); | |
} | |
await saveData(); | |
} else { | |
highlightText(nameSpan, originalName, ''); | |
} | |
}; | |
const onBlur = () => endRename(nameSpan.textContent); | |
const onKeydown = ev => { | |
if (ev.key === 'Enter') { ev.preventDefault(); nameSpan.blur(); } | |
if (ev.key === 'Escape') { highlightText(nameSpan, originalName, ''); nameSpan.blur(); } | |
}; | |
nameSpan.addEventListener('blur', onBlur); | |
nameSpan.addEventListener('keydown', onKeydown); | |
}; | |
const handleNoteUpdate = async (isForced = false) => { | |
if (isSaving && !isForced) return; | |
isSaving = true; | |
const { item: activeNote } = findItem(state.activeNoteId, CONSTANTS.ITEM_TYPE.NOTE); | |
if (activeNote) { | |
const newTitle = noteTitleInput.value, newContent = noteContentTextarea.value; | |
if (activeNote.title !== newTitle || activeNote.content !== newContent) { | |
activeNote.title = newTitle; activeNote.content = newContent; activeNote.updatedAt = Date.now(); | |
const saveAndUpdate = async () => { await saveData(); renderEditor(); renderNotes(); }; | |
if (isForced) { clearTimeout(debounceTimer); await saveAndUpdate(); } | |
else { debounce(saveAndUpdate, 500); } | |
} | |
} | |
isSaving = false; | |
}; | |
// --- 드래그 앤 드롭 --- | |
const setupDragAndDrop = (listElement, type) => { | |
listElement.addEventListener('dragstart', e => { | |
const li = e.target.closest('.item-list-entry'); | |
if (li) { | |
const id = li.dataset.id; | |
draggedItemInfo = { id, type }; | |
if (type === CONSTANTS.ITEM_TYPE.NOTE) { | |
const { folder } = findItem(id, CONSTANTS.ITEM_TYPE.NOTE); | |
if (folder) draggedItemInfo.sourceFolderId = folder.id; | |
} | |
e.dataTransfer.effectAllowed = 'move'; | |
setTimeout(() => li.classList.add(CONSTANTS.CLASSES.DRAGGING), 0); | |
} | |
}); | |
listElement.addEventListener('dragover', e => { | |
e.preventDefault(); | |
const li = e.target.closest('.item-list-entry'); | |
const isSelf = li && li.dataset.id === draggedItemInfo.id; | |
// [수정] '모든 노트' 뷰에서는 노트 순서 변경 UI(파란색 라인)가 보이지 않도록 수정 | |
if (type === CONSTANTS.ITEM_TYPE.NOTE && state.activeFolderId === CONSTANTS.ALL_NOTES_ID) { | |
document.querySelector('.drag-over-indicator')?.remove(); | |
e.dataTransfer.dropEffect = 'none'; | |
return; | |
} | |
if (isSelf || !li) { e.dataTransfer.dropEffect = 'none'; return; } | |
e.dataTransfer.dropEffect = 'move'; | |
document.querySelector('.drag-over-indicator')?.remove(); | |
const indicator = document.createElement('li'); | |
indicator.className = 'drag-over-indicator'; | |
const rect = li.getBoundingClientRect(); | |
if (e.clientY > rect.top + rect.height / 2) li.after(indicator); | |
else li.before(indicator); | |
}); | |
listElement.addEventListener('drop', async e => { | |
e.preventDefault(); | |
document.querySelector('.drag-over-indicator')?.remove(); | |
const targetLi = e.target.closest('.item-list-entry'); | |
if (!targetLi || !draggedItemInfo.id || targetLi.dataset.id === draggedItemInfo.id) return; | |
const draggedItemId = draggedItemInfo.id; | |
const targetItemId = targetLi.dataset.id; | |
let sourceList, draggedItem; | |
if (type === CONSTANTS.ITEM_TYPE.FOLDER) { | |
sourceList = state.folders; | |
} else { | |
const { folder } = findItem(draggedItemId, CONSTANTS.ITEM_TYPE.NOTE); | |
if (!folder) return; | |
sourceList = folder.notes; | |
} | |
const fromIndex = sourceList.findIndex(i => i.id === draggedItemId); | |
if(fromIndex === -1) return; | |
[draggedItem] = sourceList.splice(fromIndex, 1); | |
let toIndex = sourceList.findIndex(i => i.id === targetItemId); | |
const rect = targetLi.getBoundingClientRect(); | |
const isAfter = e.clientY > rect.top + rect.height / 2; | |
if (isAfter) toIndex++; | |
sourceList.splice(toIndex, 0, draggedItem); | |
if (type === CONSTANTS.ITEM_TYPE.NOTE) { | |
const { folder } = findItem(draggedItemId, CONSTANTS.ITEM_TYPE.NOTE); | |
if (folder) { | |
// [개선] 수동 정렬 순서 업데이트 및 모드 변경 | |
folder.noteOrder = folder.notes.map(n => n.id); | |
setState({ noteSortOrder: 'manual' }); | |
} | |
} else { | |
setState({}, { all: true }); | |
} | |
await saveData(); | |
}); | |
listElement.addEventListener('dragend', () => { | |
listElement.querySelector(`.${CONSTANTS.CLASSES.DRAGGING}`)?.classList.remove(CONSTANTS.CLASSES.DRAGGING); | |
document.querySelectorAll('.drag-over-indicator').forEach(el => el.remove()); | |
draggedItemInfo = { id: null, type: null, sourceFolderId: null }; | |
}); | |
}; | |
const setupNoteToFolderDrop = () => { | |
foldersPanel.addEventListener('dragover', e => { | |
if (draggedItemInfo.type !== CONSTANTS.ITEM_TYPE.NOTE) return; | |
e.preventDefault(); | |
const targetFolderEl = e.target.closest('.item-list-entry'); | |
document.querySelectorAll(`#folders-panel .${CONSTANTS.CLASSES.DROP_TARGET}`).forEach(el => el.classList.remove(CONSTANTS.CLASSES.DROP_TARGET)); | |
const targetFolderId = targetFolderEl?.dataset.id; | |
if (targetFolderEl && targetFolderId !== CONSTANTS.ALL_NOTES_ID && targetFolderId !== draggedItemInfo.sourceFolderId) { | |
targetFolderEl.classList.add(CONSTANTS.CLASSES.DROP_TARGET); | |
e.dataTransfer.dropEffect = 'move'; | |
} else { | |
e.dataTransfer.dropEffect = 'none'; | |
} | |
}); | |
foldersPanel.addEventListener('dragleave', e => { | |
if (!foldersPanel.contains(e.relatedTarget)) { | |
document.querySelectorAll(`#folders-panel .${CONSTANTS.CLASSES.DROP_TARGET}`).forEach(el => el.classList.remove(CONSTANTS.CLASSES.DROP_TARGET)); | |
} | |
}); | |
foldersPanel.addEventListener('drop', async e => { | |
e.preventDefault(); | |
const targetFolderEl = e.target.closest(`.${CONSTANTS.CLASSES.DROP_TARGET}`); | |
targetFolderEl?.classList.remove(CONSTANTS.CLASSES.DROP_TARGET); | |
if (!targetFolderEl || !draggedItemInfo.id || draggedItemInfo.type !== CONSTANTS.ITEM_TYPE.NOTE) return; | |
const targetFolderId = targetFolderEl.dataset.id; | |
const { folder: sourceFolder, item: noteToMove, index: noteIndex } = findItem(draggedItemInfo.id, CONSTANTS.ITEM_TYPE.NOTE); | |
const { item: targetFolder } = findItem(targetFolderId, CONSTANTS.ITEM_TYPE.FOLDER); | |
if (sourceFolder && noteToMove && targetFolder && sourceFolder.id !== targetFolder.id) { | |
sourceFolder.notes.splice(noteIndex, 1); | |
// [개선] 수동 정렬 순서에서도 제거/추가 | |
const orderIndex = sourceFolder.noteOrder.indexOf(noteToMove.id); | |
if (orderIndex > -1) sourceFolder.noteOrder.splice(orderIndex, 1); | |
noteToMove.updatedAt = Date.now(); | |
targetFolder.notes.unshift(noteToMove); | |
targetFolder.noteOrder.unshift(noteToMove.id); | |
_buildNoteMap(); | |
setState({ activeFolderId: targetFolderId, activeNoteId: noteToMove.id }); | |
await saveData(); | |
showToast(`✅ '${targetFolder.name}'(으)로 노트 이동 완료!`); | |
} | |
}); | |
}; | |
// --- 데이터 가져오기/내보내기 --- | |
const sanitizeData = data => { | |
if (!data || !Array.isArray(data.folders)) throw new Error("유효하지 않은 파일 구조입니다."); | |
const usedIds = new Set(); | |
const getUniqueId = (prefix, id) => { | |
let finalId = String(id || `${prefix}-${Date.now()}`).slice(0, 50); | |
let counter = 1; | |
while (usedIds.has(finalId)) { | |
finalId = `${String(id).slice(0, 40)}-${counter++}`; | |
} | |
usedIds.add(finalId); | |
return finalId; | |
}; | |
return { | |
folders: data.folders.map(f => { | |
const folderId = getUniqueId('folder', f.id); | |
const notes = Array.isArray(f.notes) ? f.notes.map(n => ({ | |
id: getUniqueId('note', n.id), | |
title: String(n.title || '제목 없는 노트').slice(0, 200), | |
content: String(n.content || ''), | |
createdAt: Number(n.createdAt) || Date.now(), | |
updatedAt: Number(n.updatedAt) || Date.now(), | |
isPinned: !!n.isPinned, | |
})) : []; | |
return { | |
id: folderId, | |
name: String(f.name || '제목 없는 폴더').slice(0, 100), | |
notes: notes, | |
noteOrder: notes.map(n => n.id) // [개선] 가져올 때도 noteOrder 생성 | |
}; | |
}) | |
}; | |
}; | |
// [BUG FIX] 한글 깨짐 방지를 위해 UTF-8 BOM 추가 | |
const handleExport = () => { | |
try { | |
const dataToExport = { folders: state.folders }; | |
const dataStr = JSON.stringify(dataToExport, null, 2); | |
const bom = new Uint8Array([0xEF, 0xBB, 0xBF]); // UTF-8 BOM | |
const blob = new Blob([bom, dataStr], { type: 'application/json;charset=utf-8' }); | |
const url = URL.createObjectURL(blob); | |
chrome.downloads.download({ | |
url: url, | |
filename: `new-tab-note-backup-${new Date().toISOString().slice(0,10)}.json` | |
}, () => { | |
URL.revokeObjectURL(url); | |
showToast('✅ 데이터 내보내기 성공!'); | |
}); | |
} catch (e) { | |
console.error("Export failed:", e); | |
showToast('❌ 데이터 내보내기 실패.', CONSTANTS.TOAST_TYPE.ERROR); | |
} | |
}; | |
const handleImport = () => { | |
importFileInput.click(); | |
}; | |
importFileInput.onchange = async e => { | |
const file = e.target.files[0]; | |
if (!file) return; | |
const reader = new FileReader(); | |
reader.onload = async event => { | |
try { | |
const importedData = JSON.parse(event.target.result); | |
const sanitized = sanitizeData(importedData); | |
const ok = await showModal({ type: CONSTANTS.MODAL_TYPE.CONFIRM, title: '⚠️ 데이터 가져오기', message: '데이터를 가져오면 현재 모든 데이터가 교체됩니다. 계속하시겠습니까?', confirmText: '가져오기' }); | |
if (ok) { | |
const newState = { | |
folders: sanitized.folders, | |
activeFolderId: sanitized.folders[0]?.id || CONSTANTS.ALL_NOTES_ID, | |
activeNoteId: sanitized.folders[0]?.notes[0]?.id || null, | |
searchTerm: '', | |
noteSortOrder: 'manual' // 가져온 데이터는 수동 정렬을 기본으로 함 | |
}; | |
setState(newState); | |
_buildNoteMap(); | |
await saveData(); | |
showToast('✅ 데이터를 성공적으로 가져왔습니다!'); | |
} | |
} catch (err) { | |
showToast(`❌ 가져오기 실패: ${err.message}`, CONSTANTS.TOAST_TYPE.ERROR); | |
} | |
}; | |
reader.readAsText(file); | |
e.target.value = ''; | |
}; | |
// --- 초기화 --- | |
const init = async () => { | |
folderList.addEventListener('click', e => handleListClick(e, CONSTANTS.ITEM_TYPE.FOLDER)); | |
folderList.addEventListener('dblclick', e => handleRename(e, CONSTANTS.ITEM_TYPE.FOLDER)); | |
noteList.addEventListener('click', e => handleListClick(e, CONSTANTS.ITEM_TYPE.NOTE)); | |
noteList.addEventListener('dblclick', e => handleRename(e, CONSTANTS.ITEM_TYPE.NOTE)); | |
setupDragAndDrop(folderList, CONSTANTS.ITEM_TYPE.FOLDER); | |
setupDragAndDrop(noteList, CONSTANTS.ITEM_TYPE.NOTE); | |
setupNoteToFolderDrop(); | |
addFolderBtn.addEventListener('click', handleAddFolder); | |
addNoteBtn.addEventListener('click', handleAddNote); | |
noteTitleInput.addEventListener('input', () => handleNoteUpdate()); | |
noteContentTextarea.addEventListener('input', () => handleNoteUpdate()); | |
searchInput.addEventListener('input', e => { | |
debounce(() => setState({ searchTerm: e.target.value }, { notes: true }), 300); | |
}); | |
clearSearchBtn.addEventListener('click', () => { | |
searchInput.value = ''; | |
setState({ searchTerm: '' }, { notes: true }); | |
searchInput.focus(); | |
}); | |
noteSortSelect.addEventListener('change', e => { | |
setState({ noteSortOrder: e.target.value }, { notes: true }); | |
}); | |
exportBtn.addEventListener('click', handleExport); | |
importBtn.addEventListener('click', handleImport); | |
// [개선] 안정적인 데이터 저장을 위해 beforeunload 대신 visibilitychange 사용 | |
window.addEventListener('visibilitychange', () => { | |
if (document.visibilityState === 'hidden') { | |
handleNoteUpdate(true); | |
} | |
}); | |
await loadData(); | |
}; | |
init(); | |
}); | |
newtab.html | |
<!DOCTYPE html> | |
<html lang="ko"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>새 탭 노트 v12.0 ✨</title> | |
<style> | |
@layer base, layout, components; | |
@layer base { | |
:root { | |
--bg-color: rgba(28, 28, 30, 0.7); | |
--blur-effect: blur(25px) saturate(180%); | |
--border-color: rgba(255, 255, 255, 0.125); | |
--divider-color: rgba(255, 255, 255, 0.2); | |
--font-color: #f5f5f7; | |
--font-color-dim: #a1a1a6; | |
--accent-color: #0A84FF; | |
--hover-bg-color: rgba(255, 255, 255, 0.1); | |
--active-bg-color: color-mix(in srgb, var(--accent-color) 40%, transparent); | |
--danger-color: #FF453A; | |
--success-color: #30D158; | |
--pin-color: #FFD60A; | |
--pin-bg-color: rgba(255, 214, 10, 0.1); | |
--highlight-bg-color: #FFD60A; | |
--highlight-font-color: #000; | |
--radius-md: 12px; | |
--radius-sm: 8px; | |
} | |
* { margin: 0; padding: 0; box-sizing: border-box; } | |
body { | |
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", sans-serif; | |
background-image: url('https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?auto=format&fit=crop&w=2560'); | |
background-size: cover; | |
background-position: center; | |
color: var(--font-color); | |
overflow: hidden; | |
height: 100vh; | |
} | |
::-webkit-scrollbar { width: 5px; } | |
::-webkit-scrollbar-track { background: transparent; } | |
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 10px; } | |
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.4); } | |
} | |
@layer layout { | |
.container { | |
display: grid; | |
grid-template-columns: 240px 300px 1fr; | |
height: 100vh; | |
padding: 16px; | |
gap: 16px; | |
} | |
.panel { | |
background: var(--bg-color); | |
-webkit-backdrop-filter: var(--blur-effect); | |
backdrop-filter: var(--blur-effect); | |
border: 1px solid var(--border-color); | |
border-radius: var(--radius-md); | |
display: flex; | |
flex-direction: column; | |
overflow: hidden; | |
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; | |
} | |
.panel.drop-target { | |
border-color: var(--accent-color); | |
box-shadow: 0 0 10px var(--accent-color); | |
} | |
.panel-header { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
padding: 12px 16px; | |
border-block-end: 1px solid var(--border-color); | |
flex-shrink: 0; | |
gap: 8px; | |
} | |
.main-content { padding: 0; } | |
.editor-area { display: flex; flex-direction: column; height: 100%; } | |
#editor-footer { | |
flex-shrink: 0; | |
padding: 8px 24px; | |
border-block-start: 1px solid var(--border-color); | |
display: flex; | |
gap: 16px; | |
font-size: 12px; | |
color: var(--font-color-dim); | |
background: rgba(0,0,0,0.1); | |
} | |
} | |
@layer components { | |
.panel-header { | |
h2 { font-size: 16px; font-weight: 600; flex-grow: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
.button-group { display: flex; gap: 4px; } | |
} | |
.icon-button { | |
background: none; border: none; color: var(--font-color-dim); font-size: 20px; | |
cursor: pointer; padding: 4px; border-radius: var(--radius-sm); line-height: 1; | |
transition: all 0.2s ease; | |
&:hover { color: var(--font-color); background-color: var(--hover-bg-color); } | |
} | |
.pin-btn.pinned { color: var(--pin-color); } | |
.item-list { list-style: none; overflow-y: auto; padding: 8px; flex-grow: 1; } | |
.item-list-entry { | |
display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-radius: var(--radius-sm); | |
cursor: pointer; transition: background-color 0.2s ease, opacity 0.2s ease; user-select: none; | |
&:hover { background-color: var(--hover-bg-color); } | |
&.active { background-color: var(--active-bg-color); color: white; font-weight: 500; } | |
&.dragging { opacity: 0.4; background: var(--active-bg-color); } | |
&.pinned { background-color: var(--pin-bg-color); } | |
} | |
.drag-over-indicator { height: 2px; background-color: var(--accent-color); margin-block: -1px; margin-inline: 8px; list-style: none; } | |
.pin-divider { height: 1px; background-color: var(--divider-color); margin: 6px 8px; list-style: none; } | |
.item-details { flex-grow: 1; overflow: hidden; } | |
.item-name { | |
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 2px 0; | |
&[contenteditable="true"] { background-color: rgba(255,255,255,0.15); box-shadow: 0 0 0 2px var(--accent-color); outline: none; cursor: text; padding-inline: 4px; border-radius: 4px; } | |
mark { background-color: var(--highlight-bg-color); color: var(--highlight-font-color); border-radius: 3px; padding: 0 2px; } | |
} | |
.item-snippet { | |
font-size: 12px; color: var(--font-color-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 4px; | |
mark { background-color: transparent; color: var(--highlight-bg-color); font-weight: bold; padding: 0; } | |
} | |
.item-folder-tag { | |
font-size: 11px; color: var(--font-color-dim); background-color: rgba(255,255,255,0.08); padding: 2px 6px; border-radius: 4px; white-space: nowrap; | |
} | |
.item-actions { | |
display: flex; align-items: center; opacity: 0; transition: opacity .2s ease; margin-inline-start: auto; | |
.item-list-entry:hover &, .item-list-entry.active & { opacity: 1; } | |
} | |
.delete-item-btn, .pin-btn { font-size: 16px; } | |
.delete-item-btn:hover { color: var(--danger-color); } | |
.notes-header-extra { | |
padding: 8px; border-block-end: 1px solid var(--border-color); flex-shrink: 0; display: flex; gap: 8px; align-items: center; | |
} | |
.search-container { | |
position: relative; flex-grow: 1; | |
#clear-search { display: none; } | |
&:has(#search-input:not(:placeholder-shown)) #clear-search { display: block; } | |
} | |
#search-input { width: 100%; background: rgba(0,0,0,0.2); border: 1px solid transparent; color: var(--font-color); padding: 8px 12px; border-radius: var(--radius-sm); font-size: 14px; outline: none; transition: all .2s ease; | |
&:focus { border-color: var(--accent-color); background: rgba(0,0,0,0.3); } | |
} | |
#clear-search { position: absolute; inset-inline-end: 8px; top: 50%; transform: translateY(-50%); background: 0; border: 0; color: var(--font-color-dim); font-size: 20px; cursor: pointer; } | |
#note-sort-select { | |
background: rgba(0,0,0,0.2); border: 1px solid transparent; color: var(--font-color); padding: 8px 4px; border-radius: var(--radius-sm); font-size: 13px; outline: none; flex-shrink: 0; | |
-webkit-appearance: none; appearance: none; | |
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23a1a1a6' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E"); | |
background-repeat: no-repeat; | |
background-position: right 4px center; | |
background-size: 16px; | |
padding-inline-end: 24px; | |
} | |
#note-title-input { background: transparent; border: none; color: var(--font-color); font-size: 28px; font-weight: 700; padding: 24px 24px 16px; width: 100%; outline: none; flex-shrink: 0; } | |
#note-content-textarea { background: transparent; border: none; color: var(--font-color); font-size: 16px; line-height: 1.7; width: 100%; flex-grow: 1; outline: none; resize: none; padding: 0 24px 24px; } | |
.placeholder { display: flex; flex-direction: column; gap: 10px; justify-content: center; align-items: center; height: 100%; font-size: 18px; color: var(--font-color-dim); text-align: center; } | |
.placeholder-button { background-color: var(--accent-color); color: white; border: none; padding: 10px 18px; border-radius: var(--radius-sm); font-size: 15px; font-weight: 500; cursor: pointer; transition: opacity .2s ease; margin-top: 10px; } | |
.placeholder-button:hover { opacity: 0.8; } | |
.modal { | |
background: var(--bg-color); color: var(--font-color); border: 1px solid var(--border-color); border-radius: var(--radius-md); | |
padding: 24px; width: 90%; max-width: 400px; gap: 16px; | |
margin: auto; | |
&::backdrop { background: rgba(0,0,0,0.5); -webkit-backdrop-filter: var(--blur-effect); backdrop-filter: var(--blur-effect); } | |
h3 { font-size: 18px; font-weight: 600; text-align: center; } | |
p { font-size: 14px; color: var(--font-color-dim); text-align: center; line-height: 1.5; } | |
form { display: flex; flex-direction: column; gap: 16px; } | |
} | |
.modal-input { width: 100%; background: rgba(0,0,0,0.2); border: 1px solid transparent; color: var(--font-color); padding: 10px 12px; border-radius: var(--radius-sm); font-size: 16px; outline: none; transition: all .2s ease; | |
&:focus { border-color: var(--accent-color); } | |
} | |
.modal-buttons { display: flex; justify-content: center; gap: 12px; margin-top: 8px; } | |
.modal-button { padding: 10px 20px; border-radius: var(--radius-sm); border: none; font-size: 15px; font-weight: 500; cursor: pointer; transition: opacity .2s ease; | |
&:hover { opacity: 0.8; } | |
&.confirm { background-color: var(--accent-color); color: white; } | |
&.cancel { background-color: var(--hover-bg-color); color: var(--font-color); } | |
} | |
#toast-container { position: fixed; inset-block-start: 20px; inset-inline-end: 20px; z-index: 2000; display: flex; flex-direction: column; gap: 10px; } | |
.toast { | |
padding: 12px 20px; border-radius: var(--radius-sm); color: white; font-size: 14px; font-weight: 500; | |
opacity: 0; transform: translateX(100%); animation: slideIn-out 4s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; | |
&.success { background-color: var(--success-color); } | |
&.error { background-color: var(--danger-color); } | |
} | |
@keyframes slideIn-out { 0%, 100% { opacity: 0; transform: translateX(100%); } 10%, 90% { opacity: 1; transform: translateX(0); } } | |
} | |
</style> | |
</head> | |
<body> | |
<div id="toast-container"></div> | |
<div class="container"> | |
<aside class="panel" id="folders-panel"> | |
<header class="panel-header"> | |
<h2>📁 폴더</h2> | |
<!-- [수정] 백업/복원 버튼 순서 변경 --> | |
<div class="button-group"> | |
<button id="export-btn" class="icon-button" title="데이터 내보내기">📤</button> | |
<button id="import-btn" class="icon-button" title="데이터 가져오기">📥</button> | |
<button id="add-folder-btn" class="icon-button" title="새 폴더 추가">✨</button> | |
</div> | |
</header> | |
<ul id="folder-list" class="item-list"></ul> | |
<input type="file" id="import-file-input" accept=".json" style="display: none;"> | |
</aside> | |
<nav class="panel"> | |
<header class="panel-header"> | |
<h2 id="notes-panel-title">📝 노트</h2> | |
<button id="add-note-btn" class="icon-button" title="새 노트 추가">➕</button> | |
</header> | |
<div class="notes-header-extra"> | |
<div class="search-container"> | |
<input type="search" id="search-input" placeholder="🔍 검색..."> | |
<button id="clear-search" title="검색 지우기">×</button> | |
</div> | |
<!-- [수정] '수동 정렬' 옵션 추가 --> | |
<select id="note-sort-select" title="노트 정렬"> | |
<option value="manual">수동 정렬</option> | |
<option value="updatedAt_desc">수정일 순 (최신)</option> | |
<option value="createdAt_desc">생성일 순 (최신)</option> | |
<option value="title_asc">제목 순 (ㄱ-ㅎ)</option> | |
<option value="title_desc">제목 순 (ㅎ-ㄱ)</option> | |
</select> | |
</div> | |
<ul id="note-list" class="item-list"></ul> | |
</nav> | |
<main class="panel main-content"> | |
<div id="editor-container" class="editor-area" style="display: none;"> | |
<input type="text" id="note-title-input" placeholder="🏷️ 제목을 입력하세요"> | |
<textarea id="note-content-textarea" placeholder="✍️ 자유롭게 메모를 남겨보세요..."></textarea> | |
<div id="editor-footer"></div> | |
</div> | |
<div id="placeholder-container" class="placeholder"> | |
<p style="font-size: 50px;">🚀</p> | |
<p>왼쪽에서 노트를 선택하거나<br>새로운 생각을 펼쳐보세요!</p> | |
</div> | |
</main> | |
</div> | |
<dialog id="modal" class="modal"> | |
<h3 id="modal-title"></h3> | |
<p id="modal-message"></p> | |
<form id="modal-form" method="dialog"> | |
<input type="text" id="modal-input" class="modal-input" style="display: none;"> | |
<div id="modal-buttons" class="modal-buttons"> | |
<button id="modal-cancel-btn" class="modal-button cancel" value="cancel"></button> | |
<button id="modal-confirm-btn" class="modal-button confirm" value="confirm"></button> | |
</div> | |
</form> | |
</dialog> | |
<template id="item-template"> | |
<li class="item-list-entry" draggable="true"> | |
<div class="item-details"> | |
<span class="item-name"></span> | |
<div class="item-snippet" style="display: none;"></div> | |
</div> | |
<span class="item-folder-tag" style="display: none;"></span> | |
<div class="item-actions"> | |
</div> | |
</li> | |
</template> | |
<script src="script.js"></script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment