v1.5: Integrated optional category feature, i18n stabilization, and documentation update

This commit is contained in:
leeyj
2026-04-16 15:42:02 +09:00
parent df8ae62b0e
commit aef0179c56
47 changed files with 1699 additions and 544 deletions
+144
View File
@@ -0,0 +1,144 @@
/**
* 카테고리 관리 모달 (Category Management Modal)
*/
import { API } from '../api.js';
import { I18nManager } from '../utils/I18nManager.js';
import { ThemeManager } from './ThemeManager.js';
export const CategoryManager = {
DOM: {},
onUpdateCallback: null,
init(onUpdateCallback) {
this.onUpdateCallback = onUpdateCallback;
this.DOM = {
modal: document.getElementById('categoryModal'),
closeBtn: document.getElementById('closeCategoryBtn'),
container: document.getElementById('categoryListContainer'),
input: document.getElementById('newCategoryInput'),
addBtn: document.getElementById('addCategoryBtn')
};
if (!this.DOM.modal) return;
this.DOM.closeBtn.onclick = () => this.close();
this.DOM.addBtn.onclick = () => this.handleAdd();
this.DOM.input.onkeydown = (e) => { if (e.key === 'Enter') this.handleAdd(); };
window.addEventListener('click', (e) => {
if (e.target === this.DOM.modal) this.close();
});
},
open() {
this.render();
this.DOM.modal.classList.add('active');
this.DOM.input.focus();
},
close() {
this.DOM.modal.classList.remove('active');
},
async render() {
const settings = ThemeManager.settings || {};
const categories = settings.categories || [];
const pinned = settings.pinned_categories || [];
this.DOM.container.innerHTML = '';
categories.forEach(cat => {
const isPinned = pinned.includes(cat);
const item = document.createElement('div');
item.className = 'cat-item';
item.innerHTML = `
<div class="cat-name">${cat}</div>
<div class="cat-actions">
<button class="cat-action-btn pin ${isPinned ? 'active' : ''}" title="Pin/Unpin Slot">
${isPinned ? '📍' : '📌'}
</button>
<button class="cat-action-btn delete" title="Delete Category">🗑️</button>
</div>
`;
// 핀 토글
item.querySelector('.pin').onclick = () => this.togglePin(cat);
// 삭제
item.querySelector('.delete').onclick = () => this.deleteCategory(cat);
this.DOM.container.appendChild(item);
});
if (categories.length === 0) {
this.DOM.container.innerHTML = `<p style="text-align:center; color:var(--muted); font-size:0.9rem; padding:20px;">${I18nManager.t('label_no_category')}</p>`;
}
},
async handleAdd() {
const name = this.DOM.input.value.trim();
if (!name) return;
if (name.length > 20) {
alert("Name too long (max 20)");
return;
}
const settings = { ...ThemeManager.settings };
if (settings.categories.includes(name)) {
alert("Already exists");
return;
}
settings.categories.push(name);
// 공간이 있으면 자동 핀 고정
if (settings.pinned_categories.length < 3) {
settings.pinned_categories.push(name);
}
await this.save(settings);
this.DOM.input.value = '';
this.render();
},
async togglePin(cat) {
const settings = { ...ThemeManager.settings };
const idx = settings.pinned_categories.indexOf(cat);
if (idx > -1) {
settings.pinned_categories.splice(idx, 1);
} else {
if (settings.pinned_categories.length >= 3) {
alert(I18nManager.t('msg_category_limit'));
return;
}
settings.pinned_categories.push(cat);
}
await this.save(settings);
this.render();
},
async deleteCategory(cat) {
if (!confirm(I18nManager.t('msg_confirm_delete_category'))) return;
const settings = { ...ThemeManager.settings };
settings.categories = settings.categories.filter(c => c !== cat);
settings.pinned_categories = settings.pinned_categories.filter(c => c !== cat);
await this.save(settings);
this.render();
},
async save(settings) {
try {
await API.saveSettings(settings);
// 전역 세팅 업데이트 및 UI 리프레시
ThemeManager.settings = settings;
if (window.UI) window.UI._updateSettingsCache(settings);
if (this.onUpdateCallback) this.onUpdateCallback();
} catch (err) {
alert("Save failed: " + err.message);
}
}
};
+101 -88
View File
@@ -5,12 +5,17 @@ import { API } from '../api.js';
import { EditorManager } from '../editor.js';
import { I18nManager } from '../utils/I18nManager.js';
import { Constants } from '../utils/Constants.js';
import { AppService } from '../AppService.js';
import { ThemeManager } from './ThemeManager.js';
// --- NEW 서브 모듈 임포트 ---
import { ComposerDraft } from './composer/ComposerDraft.js';
import { ComposerCategoryUI } from './composer/ComposerCategoryUI.js';
export const ComposerManager = {
DOM: {},
init(onSaveSuccess) {
// 타이밍 이슈 방지를 위해 DOM 요소 지연 할당
this.DOM = {
trigger: document.getElementById('composerTrigger'),
composer: document.getElementById('composer'),
@@ -21,11 +26,15 @@ export const ComposerManager = {
encryptionToggle: document.getElementById('encryptionToggle'),
password: document.getElementById('memoPassword'),
foldBtn: document.getElementById('foldBtn'),
discardBtn: document.getElementById('discardBtn')
discardBtn: document.getElementById('discardBtn'),
categoryBar: document.getElementById('composerCategoryBar')
};
if (!this.DOM.composer || !this.DOM.trigger) return;
this.selectedCategory = null;
this.isDoneStatus = false;
// 1. 이벤트 바인딩
this.DOM.trigger.onclick = () => this.openEmpty();
this.DOM.foldBtn.onclick = () => this.close();
@@ -44,28 +53,41 @@ export const ComposerManager = {
};
this.DOM.encryptionToggle.onclick = () => this.toggleEncryption();
// 단축키 힌트 토글 바인딩
const shortcutToggle = document.getElementById('shortcutToggle');
const shortcutDetails = document.getElementById('shortcutDetails');
if (shortcutToggle && shortcutDetails) {
shortcutToggle.onclick = () => {
const isVisible = shortcutDetails.style.display !== 'none';
shortcutDetails.style.display = isVisible ? 'none' : 'flex';
const label = I18nManager.t('shortcuts_label');
shortcutToggle.textContent = isVisible ? label : `${label}`;
this.initShortcutHint();
// 2. 자동 임시저장 및 키보드 리스너 등록
this.draftTimer = setInterval(() => this.saveDraft(), 3000);
ComposerDraft.checkRestore((draft) => this.restoreDraft(draft));
document.addEventListener('keydown', (e) => this.handleKeyDown(e));
},
initShortcutHint() {
const toggle = document.getElementById('shortcutToggle');
const details = document.getElementById('shortcutDetails');
if (toggle && details) {
toggle.onclick = () => {
const isVisible = details.style.display !== 'none';
details.style.display = isVisible ? 'none' : 'flex';
toggle.textContent = isVisible ? I18nManager.t('shortcuts_label') : `${I18nManager.t('shortcuts_label')}`;
};
}
// --- 자동 임시저장 (Auto-Draft) ---
this.draftTimer = setInterval(() => this.saveDraft(), 3000);
this.checkDraftRestore();
},
openEmpty() {
this.clear();
// 컨텍스트 기반 그룹 자동 설정 (all, done, tag 제외)
const currentGroup = AppService.state.currentFilterGroup;
if (currentGroup &&
currentGroup !== 'all' &&
currentGroup !== Constants.GROUPS.DONE &&
!currentGroup.startsWith('tag:')) {
this.DOM.group.value = currentGroup;
}
this.DOM.composer.style.display = 'block';
this.DOM.trigger.style.display = 'none';
this.renderCategoryChips(); // 💡 초기화 후 칩 렌더링
this.DOM.title.focus();
},
@@ -77,6 +99,10 @@ export const ComposerManager = {
this.DOM.group.value = memo.group_name || Constants.GROUPS.DEFAULT;
this.DOM.tags.value = (memo.tags || []).filter(t => t.source === 'user').map(t => t.name).join(', ');
// 💡 분류 및 상태 복원
this.selectedCategory = memo.category || null;
this.isDoneStatus = memo.status === 'done';
EditorManager.setMarkdown(memo.content || '');
EditorManager.setAttachedFiles(memo.attachments || []);
@@ -86,6 +112,7 @@ export const ComposerManager = {
this.DOM.composer.style.display = 'block';
this.DOM.trigger.style.display = 'none';
this.renderCategoryChips(); // 💡 렌더링
window.scrollTo({ top: 0, behavior: 'smooth' });
},
@@ -94,6 +121,8 @@ export const ComposerManager = {
title: this.DOM.title.value.trim(),
content: EditorManager.getMarkdown(),
group_name: this.DOM.group.value.trim() || Constants.GROUPS.DEFAULT,
category: this.selectedCategory,
status: this.isDoneStatus ? 'done' : 'active',
tags: this.DOM.tags.value.split(',').map(t => t.trim()).filter(t => t),
is_encrypted: this.DOM.encryptionToggle.dataset.locked === 'true',
password: this.DOM.password.value.trim(),
@@ -106,7 +135,7 @@ export const ComposerManager = {
try {
await API.saveMemo(data, this.DOM.id.value);
EditorManager.sessionFiles.clear();
this.clearDraft();
ComposerDraft.clear(); // 💡 서브 모듈 위임
if (callback) await callback();
this.clear();
this.close();
@@ -123,9 +152,12 @@ export const ComposerManager = {
this.DOM.title.value = '';
this.DOM.group.value = Constants.GROUPS.DEFAULT;
this.DOM.tags.value = '';
this.selectedCategory = null;
this.isDoneStatus = false;
EditorManager.setMarkdown('');
EditorManager.setAttachedFiles([]);
this.setLocked(false);
this.renderCategoryChips();
},
toggleEncryption() {
@@ -137,89 +169,70 @@ export const ComposerManager = {
this.DOM.encryptionToggle.dataset.locked = locked;
this.DOM.encryptionToggle.innerText = locked ? '🔒' : '🔓';
this.DOM.password.style.display = locked ? 'block' : 'none';
// 비밀번호가 명시적으로 전달된 경우에만 업데이트 (해제 시 기존 비번 유지)
if (password !== null) {
this.DOM.password.value = password;
}
if (locked && !this.DOM.password.value) {
this.DOM.password.focus();
}
if (password !== null) this.DOM.password.value = password;
if (locked && !this.DOM.password.value) this.DOM.password.focus();
},
// === 자동 임시저장 (Auto-Draft) ===
// --- 서브 모듈 위임 메서드들 ---
/**
* 현재 에디터 내용을 localStorage에 자동 저장
*/
saveDraft() {
// 컴포저가 닫혀있으면 저장하지 않음
if (this.DOM.composer.style.display !== 'block') return;
const title = this.DOM.title.value;
const content = EditorManager.getMarkdown();
// 내용이 비어있으면 저장하지 않음
if (!title && !content) return;
const draft = {
title: title,
content: content,
group: this.DOM.group.value,
tags: this.DOM.tags.value,
editingId: this.DOM.id.value,
timestamp: Date.now()
};
localStorage.setItem('memo_draft', JSON.stringify(draft));
ComposerDraft.save(
this.DOM.id.value,
this.DOM.title.value,
this.DOM.group.value,
this.DOM.tags.value,
EditorManager.getMarkdown()
);
},
/**
* 페이지 로드 시 임시저장된 내용이 있으면 복원 확인
*/
checkDraftRestore() {
const raw = localStorage.getItem('memo_draft');
if (!raw) return;
restoreDraft(draft) {
this.openEmpty();
this.DOM.title.value = draft.title || '';
this.DOM.group.value = draft.group || Constants.GROUPS.DEFAULT;
this.DOM.tags.value = draft.tags || '';
if (draft.editingId) this.DOM.id.value = draft.editingId;
EditorManager.setMarkdown(draft.content || '');
},
try {
const draft = JSON.parse(raw);
// 24시간 이상 된 임시저장은 자동 삭제
if (Date.now() - draft.timestamp > 86400000) {
this.clearDraft();
return;
renderCategoryChips() {
ComposerCategoryUI.render(
this.DOM.categoryBar,
this.selectedCategory,
this.isDoneStatus,
{
onSelect: (cat) => {
this.selectedCategory = (this.selectedCategory === cat) ? null : cat;
this.renderCategoryChips();
},
onToggleDone: () => {
this.isDoneStatus = !this.isDoneStatus;
this.renderCategoryChips();
}
}
);
},
// 내용이 실제로 있는 경우에만 복원 확인
if (!draft.title && !draft.content) {
this.clearDraft();
return;
}
handleKeyDown(e) {
if (this.DOM.composer.style.display !== 'block') return;
if (!e.altKey) return;
const titlePreview = draft.title || I18nManager.t('label_untitled');
const confirmMsg = I18nManager.t('msg_draft_restore_confirm')
.replace('{title}', titlePreview);
if (confirm(confirmMsg)) {
this.openEmpty();
this.DOM.title.value = draft.title || '';
this.DOM.group.value = draft.group || Constants.GROUPS.DEFAULT;
this.DOM.tags.value = draft.tags || '';
if (draft.editingId) this.DOM.id.value = draft.editingId;
EditorManager.setMarkdown(draft.content || '');
} else {
this.clearDraft();
const key = e.key;
if (key === '1') {
e.preventDefault();
this.isDoneStatus = !this.isDoneStatus;
this.renderCategoryChips();
} else if (key === '2' || key === '3' || key === '4') {
e.preventDefault();
const cat = ComposerCategoryUI.getCategoryBySlot(parseInt(key) - 1);
if (cat) {
this.selectedCategory = (this.selectedCategory === cat) ? null : cat;
this.renderCategoryChips();
}
} catch (e) {
console.warn('[Draft] Failed to parse draft, deleting:', e);
this.clearDraft();
} else if (key === '5') {
e.preventDefault();
this.selectedCategory = null;
this.renderCategoryChips();
}
},
/**
* 임시저장 데이터 삭제
*/
clearDraft() {
localStorage.removeItem('memo_draft');
}
};
+6 -1
View File
@@ -36,8 +36,13 @@ export function createMemoCardHtml(memo, isDone) {
</div>
`;
} else {
// 본문에서 하단 메타데이터 블록(--- 이후)을 제외하고 렌더링 (중복 표시 방지)
let content = memo.content || '';
const footerIndex = content.lastIndexOf('\n\n---\n');
const displayContent = footerIndex !== -1 ? content.substring(0, footerIndex) : content;
// marked로 파싱한 후 DOMPurify로 살균하여 XSS 방지
htmlContent = DOMPurify.sanitize(marked.parse(memo.content || ''));
htmlContent = DOMPurify.sanitize(marked.parse(displayContent));
htmlContent = parseInternalLinks(htmlContent);
htmlContent = fixImagePaths(htmlContent);
}
+15 -2
View File
@@ -165,8 +165,21 @@ export const ModalManager = {
if (!memo) return;
import('../utils.js').then(({ parseInternalLinks, fixImagePaths }) => {
// 마크다운 파싱 후 살균 처리 (marked, DOMPurify는 global 사용)
let html = DOMPurify.sanitize(marked.parse(memo.content));
// 메모 본문과 메타데이터 푸터 분리 렌더링
let content = memo.content || '';
const footerIndex = content.lastIndexOf('\n\n---\n');
let html;
if (footerIndex !== -1) {
const mainBody = content.substring(0, footerIndex);
const footerPart = content.substring(footerIndex + 5).trim(); // '---' 이후
html = DOMPurify.sanitize(marked.parse(mainBody));
html += `<div class="memo-metadata-footer"><hr style="border:none; border-top:1px dashed rgba(255,255,255,0.1); margin-bottom:15px;">${DOMPurify.sanitize(marked.parse(footerPart))}</div>`;
} else {
html = DOMPurify.sanitize(marked.parse(content));
}
html = parseInternalLinks(html);
html = fixImagePaths(html);
+24
View File
@@ -45,3 +45,27 @@ export function renderGroupList(container, groups, activeGroup, onGroupClick) {
container.appendChild(li);
});
}
/**
* 카테고리 목록 HTML 렌더링 (Pinned Categories 전용)
*/
export function renderCategoryList(container, pinnedCategories, activeCategory, onCategoryClick) {
if (!container) return;
container.innerHTML = '';
pinnedCategories.forEach(cat => {
const li = document.createElement('li');
li.className = (cat === activeCategory) ? 'active' : '';
li.title = cat; // 💡 사이드바 축소 시 이름을 보여주기 위해 title 추가
li.innerHTML = `<span class="icon">🏷️</span> <span class="text">${escapeHTML(cat)}</span>`;
li.onclick = () => onCategoryClick(cat);
container.appendChild(li);
});
if (pinnedCategories.length === 0) {
const li = document.createElement('li');
li.style.cssText = 'font-size: 0.8rem; color: var(--muted); padding: 5px 15px; cursor: default;';
li.textContent = I18nManager.t('label_no_category');
container.appendChild(li);
}
}
+34 -4
View File
@@ -27,7 +27,13 @@ export const ThemeManager = {
}
// ... 나머지 모달 제어 로직 유지 (기존 코드와 동일)
if (settingsBtn) settingsBtn.onclick = () => settingsModal.classList.add('active');
if (settingsBtn) {
settingsBtn.onclick = () => {
const langSelect = document.getElementById('set-lang');
if (langSelect) this.initialLang = langSelect.value;
settingsModal.classList.add('active');
};
}
if (closeSettingsBtn) closeSettingsBtn.onclick = () => settingsModal.classList.remove('active');
window.addEventListener('click', (e) => {
@@ -57,13 +63,23 @@ export const ThemeManager = {
data[mapping[p.id]] = p.value;
});
data['enable_ai'] = document.getElementById('set-enable-ai').checked;
data['enable_categories'] = document.getElementById('set-enable-categories').checked;
// 언어 설정이 UI에 있다면 추가 (현재는 config.json 수동 명시 권장이나 대비책 마련)
const langSelect = document.getElementById('set-lang');
if (langSelect) data['lang'] = langSelect.value;
const newLang = langSelect ? langSelect.value : (this.initialLang || 'ko');
if (langSelect) data['lang'] = newLang;
try {
await API.saveSettings(data);
// 언어가 변경되었다면 페이지를 새로고침하여 모든 매니저들을 새로운 언어로 재초기화합니다.
if (this.initialLang && this.initialLang !== newLang) {
alert(I18nManager.t('msg_settings_saved'));
window.location.reload();
return;
}
await this.applyTheme(data);
alert(I18nManager.t('msg_settings_saved'));
settingsModal.classList.remove('active');
@@ -80,7 +96,8 @@ export const ThemeManager = {
card_color: "rgba(30, 41, 59, 0.85)",
encrypted_border: "#00f3ff",
ai_accent: "#8b5cf6",
lang: "ko"
lang: "ko",
enable_categories: false
};
this.applyTheme(defaults);
}
@@ -92,6 +109,11 @@ export const ThemeManager = {
* 테마 데이터를 실제 CSS 변수 및 UI 요소에 반영
*/
async applyTheme(settings) {
this.settings = settings; // NEW: 설정 캐시 저장
if (window.UI) {
window.UI._updateSettingsCache(settings);
}
const mapping = {
'bg_color': '--bg',
'sidebar_color': '--sidebar',
@@ -117,7 +139,15 @@ export const ThemeManager = {
const aiToggle = document.getElementById('set-enable-ai');
if (aiToggle) aiToggle.checked = enableAI;
// 3. i18n 적용
// 3. 카테고리 활성화 상태 적용 (고급 옵션)
const enableCategories = (settings.enable_categories === true);
const catToggle = document.getElementById('set-enable-categories');
if (catToggle) catToggle.checked = enableCategories;
if (window.UI && typeof window.UI.applyCategoryVisibility === 'function') {
window.UI.applyCategoryVisibility(enableCategories);
}
// 4. i18n 적용
const lang = settings.lang || 'ko';
await I18nManager.init(lang);
const langSelect = document.getElementById('set-lang');
@@ -0,0 +1,73 @@
/**
* 작성기 카테고리/핀 UI 렌더링 엔진
*/
import { I18nManager } from '../../utils/I18nManager.js';
import { ThemeManager } from '../ThemeManager.js';
export const ComposerCategoryUI = {
/**
* 카테고리 칩 및 상태 UI 렌더링
*/
render(container, selectedCategory, isDoneStatus, handlers) {
if (!container) return;
const settings = ThemeManager.settings || {};
const slots = settings.pinned_categories || [];
container.innerHTML = '';
// 💡 인라인 스타일 대신 클래스로 관리하거나 layout.css의 #composerCategoryBar 설정을 따름
// 1. 완료 칩 (Alt + 1)
const doneChip = document.createElement('div');
doneChip.className = `cat-chip done-chip ${isDoneStatus ? 'active' : ''}`;
doneChip.innerHTML = `<span class="icon">✅</span> <span class="text">${I18nManager.t('label_category_done')}</span> <kbd>Alt+1</kbd>`;
doneChip.onclick = () => handlers.onToggleDone();
container.appendChild(doneChip);
// 2. 외부 카테고리 강조칩 (핀에 없지만 지정된 경우)
const isExternal = selectedCategory && !slots.includes(selectedCategory);
if (isExternal) {
const extChip = document.createElement('div');
extChip.className = 'cat-chip external-active active';
extChip.innerHTML = `<span class="icon">📍</span> ${selectedCategory}`;
extChip.title = `Current: ${selectedCategory}`;
extChip.onclick = () => handlers.onSelect(selectedCategory);
container.appendChild(extChip);
const divider = document.createElement('div');
divider.className = 'chip-divider';
// 구분선 스타일은 CSS에서 관리하거나 최소한으로 유지
divider.style.cssText = 'width: 1px; height: 12px; background: var(--muted); opacity: 0.3; margin: 0 5px;';
container.appendChild(divider);
}
// 3. 핀 슬롯(1~3번) 렌더링
slots.forEach((cat, idx) => {
const slotNum = idx + 2; // 완료(1) 다음부터 시작
const key = `shortcut_cat_${idx + 1}`;
const label = I18nManager.t(key).replace('%s', cat);
const chip = document.createElement('div');
chip.className = `cat-chip ${selectedCategory === cat ? 'active' : ''}`;
chip.innerHTML = `<span class="icon">🏷️</span> <span class="text">${cat}</span> <kbd>Alt+${slotNum}</kbd>`;
chip.title = label;
chip.onclick = () => handlers.onSelect(cat);
container.appendChild(chip);
});
// 4. Alt+5: 분류 해제 힌트
const clearHint = document.createElement('div');
clearHint.className = 'shortcut-hint';
clearHint.textContent = I18nManager.t('shortcut_cat_clear');
container.appendChild(clearHint);
},
/**
* 슬롯 인덱스 기반으로 어떤 카테고리를 토글할지 결정
*/
getCategoryBySlot(index) {
const settings = ThemeManager.settings || {};
const slots = settings.pinned_categories || [];
return slots[index - 1] || null;
}
};
@@ -0,0 +1,69 @@
/**
* 작성기 임시저장(Draft) 관리 모듈
*/
import { I18nManager } from '../../utils/I18nManager.js';
import { Constants } from '../../utils/Constants.js';
export const ComposerDraft = {
/**
* 현재 에디터 내용을 localStorage에 자동 저장
*/
save(id, title, group, tags, content) {
// 내용이 비어있으면 저장하지 않음
if (!title && !content) return;
const draft = {
title,
content,
group: group || Constants.GROUPS.DEFAULT,
tags: tags || '',
editingId: id || '',
timestamp: Date.now()
};
localStorage.setItem('memo_draft', JSON.stringify(draft));
},
/**
* 임시저장된 내용이 있는지 확인하고 복원 처리
*/
checkRestore(onRestore) {
const raw = localStorage.getItem('memo_draft');
if (!raw) return;
try {
const draft = JSON.parse(raw);
// 24시간 이상 된 임시저장은 자동 삭제
if (Date.now() - draft.timestamp > 86400000) {
this.clear();
return;
}
// 내용이 실제로 있는 경우에만 복원 확인
if (!draft.title && !draft.content) {
this.clear();
return;
}
const titlePreview = draft.title || I18nManager.t('label_untitled');
const confirmMsg = I18nManager.t('msg_draft_restore_confirm')
.replace('{title}', titlePreview);
if (confirm(confirmMsg)) {
onRestore(draft);
} else {
this.clear();
}
} catch (e) {
console.warn('[Draft] Failed to parse draft, deleting:', e);
this.clear();
}
},
/**
* 임시저장 데이터 삭제
*/
clear() {
localStorage.removeItem('memo_draft');
}
};