mirror of
https://github.com/sotam0316/brain_dogfood.git
synced 2026-04-25 03:48:38 +09:00
v1.5: Integrated optional category feature, i18n stabilization, and documentation update
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user