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
+23 -2
View File
@@ -10,6 +10,7 @@ import { CalendarManager } from './js/components/CalendarManager.js';
import { Visualizer } from './js/components/Visualizer.js';
import { HeatmapManager } from './js/components/HeatmapManager.js';
import { DrawerManager } from './js/components/DrawerManager.js';
import { CategoryManager } from './js/components/CategoryManager.js';
import { ModalManager } from './js/components/ModalManager.js';
import { I18nManager } from './js/utils/I18nManager.js';
import { Constants } from './js/utils/Constants.js';
@@ -27,17 +28,20 @@ document.addEventListener('DOMContentLoaded', async () => {
AppService.setFilter({ date }, updateSidebarCallback);
});
DrawerManager.init();
CategoryManager.init(() => AppService.refreshData(updateSidebarCallback));
Visualizer.init('graphContainer');
UI.initSidebarToggle();
// --- 🔹 Callbacks ---
const updateSidebarCallback = (memos, activeGroup) => {
UI.updateSidebar(memos, activeGroup, (newFilter) => {
const updateSidebarCallback = (memos, activeGroup, activeCategory) => {
UI.updateSidebar(memos, activeGroup, activeCategory, (newFilter) => {
if (newFilter === Constants.GROUPS.FILES) {
ModalManager.openAssetLibrary((id, ms) => UI.openMemoModal(id, ms));
} else {
AppService.setFilter({ group: newFilter }, updateSidebarCallback);
}
}, (newCat) => {
AppService.setFilter({ category: newCat }, updateSidebarCallback);
});
};
@@ -138,6 +142,13 @@ document.addEventListener('DOMContentLoaded', async () => {
});
};
// --- 🔹 Category Management ---
document.getElementById('manageCategoryBtn').onclick = () => {
CategoryManager.open();
};
// --- 🔹 Global Shortcuts (Comprehensive Shift to Ctrl-based System) ---
// --- 🔹 Global Shortcuts (Comprehensive Shift to Ctrl-based System) ---
document.addEventListener('keydown', (e) => {
const isCtrl = e.ctrlKey || e.metaKey;
@@ -189,6 +200,16 @@ document.addEventListener('DOMContentLoaded', async () => {
if (isAlt && key === '`') {
e.preventDefault();
ComposerManager.openEmpty();
return;
}
// 5. Category Slots: Alt + 1~4
if (isAlt && (key >= '1' && key <= '4')) {
if (ComposerManager.DOM.composer.style.display === 'block') {
e.preventDefault();
const slotIndex = parseInt(key) - 1; // 1->0 (Done), 2->1 (Cat1)...
ComposerManager.toggleCategoryBySlot(slotIndex);
}
}
});
+152
View File
@@ -105,3 +105,155 @@
.action-btn:hover { background: rgba(184, 59, 94, 0.8); }
.ai-btn:hover { background: var(--ai-accent); color: white; }
/* Memo Footer Metadata Styling */
.memo-metadata-footer {
margin-top: 20px;
padding-top: 15px;
border-top: 1px dashed rgba(255, 255, 255, 0.1);
color: var(--muted);
font-size: 0.85rem;
opacity: 0.7;
}
.memo-metadata-footer p {
margin: 5px 0;
}
/* === Composer Category Chips === */
#composerCategoryBar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 15px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
flex-wrap: wrap;
}
.cat-chip {
padding: 6px 14px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
color: var(--text-dim);
display: flex;
align-items: center;
gap: 6px;
user-select: none;
}
.cat-chip:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--accent-light);
transform: translateY(-1px);
color: white;
}
.cat-chip.active {
background: var(--accent-light);
color: white;
border-color: var(--accent-light);
box-shadow: 0 4px 12px rgba(56, 189, 248, 0.3);
font-weight: 600;
}
.cat-chip.done-chip.active {
background: #10b981; /* Success Green */
border-color: #10b981;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
/* 💡 외부 카테고리 강조칩 (핀에 없지만 지정된 경우) */
.cat-chip.external-active {
border: 1px dashed #ff4d4d !important;
background: rgba(255, 77, 77, 0.05);
color: #ff4d4d !important;
}
.cat-chip.external-active:hover {
background: rgba(255, 77, 77, 0.12);
}
.cat-chip kbd {
font-size: 10px;
opacity: 0.6;
margin-left: 4px;
background: rgba(0, 0, 0, 0.2);
padding: 1px 4px;
border-radius: 3px;
}
/* Category Management List */
.cat-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 10px;
margin-bottom: 6px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.cat-item:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-1px);
}
.cat-item .cat-name {
flex: 1;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
}
.cat-actions {
display: flex;
gap: 15px;
}
.cat-action-btn {
background: transparent;
border: none;
cursor: pointer;
font-size: 1.1rem;
padding: 2px;
opacity: 0.4;
transition: all 0.2s;
}
.cat-action-btn:hover {
opacity: 1;
transform: scale(1.2);
}
.cat-action-btn.pin.active {
opacity: 1;
color: var(--accent);
filter: drop-shadow(0 0 5px var(--accent));
}
.cat-action-btn.delete:hover {
color: #f87171;
}
/* Custom Scrollbar for Category List */
#categoryListContainer::-webkit-scrollbar {
width: 6px;
}
#categoryListContainer::-webkit-scrollbar-track {
background: transparent;
}
#categoryListContainer::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
#categoryListContainer::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
+24 -9
View File
@@ -10,6 +10,7 @@ export const AppService = {
state: {
memosCache: [],
currentFilterGroup: 'all',
currentFilterCategory: null, // NEW: 카테고리 필터
currentFilterDate: null,
currentSearchQuery: '',
offset: 0,
@@ -42,11 +43,11 @@ export const AppService = {
if (this.state.isLoading || !this.state.hasMore) return;
this.state.isLoading = true;
// UI.showLoading(true)는 호출부에서 관리하거나 여기서 직접 호출 가능
try {
const filters = {
group: this.state.currentFilterGroup,
category: this.state.currentFilterCategory, // NEW
date: this.state.currentFilterDate,
query: this.state.currentSearchQuery,
offset: this.state.offset,
@@ -68,13 +69,10 @@ export const AppService = {
this.state.offset += newMemos.length;
// 캘린더 점 표시는 첫 로드 시에면 하면 부족할 수 있으므로,
// 필요 시 전체 데이터를 새로 고침하는 별도 API가 필요할 수 있음.
// 여기서는 현재 캐시된 데이터 기반으로 업데이트.
CalendarManager.updateMemoDates(this.state.memosCache);
if (onUpdateSidebar) {
onUpdateSidebar(this.state.memosCache, this.state.currentFilterGroup);
onUpdateSidebar(this.state.memosCache, this.state.currentFilterGroup, this.state.currentFilterCategory);
}
UI.setHasMore(this.state.hasMore);
@@ -90,17 +88,34 @@ export const AppService = {
/**
* 필터 상태를 변경하고 데이터 초기화 후 다시 로딩
*/
async setFilter({ group, date, query }, onUpdateSidebar) {
async setFilter({ group, category, date, query }, onUpdateSidebar) {
let changed = false;
if (group !== undefined && this.state.currentFilterGroup !== group) {
this.state.currentFilterGroup = group;
// 1. 그룹 선택 처리
if (group !== undefined) {
// 그룹이 바뀌거나, 혹은 카테고리가 켜져있는 상태에서 그룹을 누르면 카테고리 해제
if (this.state.currentFilterGroup !== group || this.state.currentFilterCategory !== null) {
this.state.currentFilterGroup = group;
this.state.currentFilterCategory = null;
changed = true;
}
}
// 2. 카테고리 선택 처리
if (category !== undefined) {
if (this.state.currentFilterCategory === category) {
// 이미 선택된 카테고리 재클릭 시 해제 (Toggle)
this.state.currentFilterCategory = null;
} else {
this.state.currentFilterCategory = category;
}
this.state.currentFilterGroup = 'all'; // 카테고리 필터 적용/변경 시 그룹 초기화
changed = true;
}
if (date !== undefined && this.state.currentFilterDate !== date) {
this.state.currentFilterDate = date;
changed = true;
// UI 동기화
CalendarManager.setSelectedDate(date);
if (HeatmapManager.setSelectedDate) {
HeatmapManager.setSelectedDate(date);
+14 -4
View File
@@ -18,12 +18,22 @@ export const API = {
async fetchMemos(filters = {}) {
const { limit = 20, offset = 0, group = 'all', query = '' } = filters;
const date = filters.date || ''; // null이나 undefined를 빈 문자열로 변환
const params = new URLSearchParams({ limit, offset, group, query, date });
const date = filters.date || '';
const category = (filters.category === null || filters.category === undefined) ? '' : filters.category;
const params = new URLSearchParams({
limit,
offset,
group,
query,
category,
date,
_t: Date.now() // 브라우저 캐시 방지용 타임스탬프
});
return await this.request(`/api/memos?${params.toString()}`);
},
async fetchHeatmapData(days = 365) {
return await this.request(`/api/stats/heatmap?days=${days}`);
return await this.request(`/api/stats/heatmap?days=${days}&_t=${Date.now()}`);
},
async saveMemo(payload, id = null) {
@@ -52,7 +62,7 @@ export const API = {
},
async fetchAssets() {
return await this.request('/api/assets');
return await this.request(`/api/assets?_t=${Date.now()}`);
},
async uploadFile(file) {
+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');
}
};
+7
View File
@@ -9,6 +9,13 @@ export const EditorManager = {
sessionFiles: new Set(), // 이번 세션에 새로 추가된 파일 트래킹 (취소 시 삭제용)
init(elSelector, onCtrlEnter) {
// 이미 초기화된 경우 기존 에디터 인스턴스 반환 및 중복 방지
const container = document.querySelector(elSelector);
if (this.editor && container && container.querySelector('.toastui-editor-defaultUI')) {
console.log('[Editor] Already initialized, skipping init.');
return this.editor;
}
const isMobile = window.innerWidth <= 768;
// --- 플러그인 설정 (글자 색상) ---
+44 -1
View File
@@ -19,6 +19,9 @@ const DOM = {
scrollSentinel: document.getElementById('scrollSentinel')
};
// 모듈 레벨의 설정 캐시 관리 (this 바인딩 문제 해결)
let settingsCache = {};
export const UI = {
/**
* 사이드바 및 로그아웃 버튼 초기화
@@ -98,14 +101,51 @@ export const UI = {
/**
* 사이드바 시스템 고정 메뉴 상태 갱신
*/
updateSidebar(memos, activeGroup, onGroupClick) {
updateSidebar(memos, activeGroup, activeCategory, onGroupClick, onCategoryClick) {
if (!DOM.systemNav) return;
// 1. 시스템 그룹 동기화
DOM.systemNav.querySelectorAll('li').forEach(li => {
const group = li.dataset.group;
li.className = (group === activeGroup) ? 'active' : '';
li.onclick = () => onGroupClick(group);
});
// 2. 카테고리 동기화 (Pinned Categories)
import('./components/SidebarUI.js').then(({ renderCategoryList }) => {
const categoryNav = document.getElementById('categoryNav');
// 💡 settingsCache가 비어있을 경우 ThemeManager에서 직접 복구 시도
const pinned = settingsCache.pinned_categories || (ThemeManager.settings ? ThemeManager.settings.pinned_categories : []);
renderCategoryList(categoryNav, pinned, activeCategory, onCategoryClick);
});
},
/**
* 카테고리 기능 활성화 여부에 따라 UI 요소 노출 제어
*/
applyCategoryVisibility(enabled) {
const composerBar = document.getElementById('composerCategoryBar');
const sidebarSection = document.getElementById('categorySidebarSection');
if (composerBar) {
// 작성기 칩 영역은 가로 정렬을 위해 flex 레이아웃이 필수입니다.
composerBar.style.display = enabled ? 'flex' : 'none';
}
if (sidebarSection) {
// 사이드바 섹션은 기본 블록 레이아웃을 사용합니다.
sidebarSection.style.display = enabled ? 'block' : 'none';
}
console.log(`Category UI visibility updated: ${enabled ? 'VISIBLE' : 'HIDDEN'}`);
},
/**
* 설정 캐시 업데이트 (내부용)
*/
_updateSettingsCache(settings) {
settingsCache = settings;
},
/**
@@ -201,6 +241,9 @@ export const UI = {
}
};
// 전역 동기화를 위해 window 객체에 할당
window.UI = UI;
/**
* 전역 파일 다운로드 함수 (항상 전역 스코프 유지)
*/
+12 -1
View File
@@ -11,6 +11,8 @@
"nav_logout": "Logout",
"nav_settings": "Settings",
"nav_toggle": "Toggle Sidebar",
"nav_categories": "Categories",
"nav_category_manage": "Manage Categories",
"search_placeholder": "Search memos... (Title, Content, Tag)",
"composer_placeholder": "Leave a fragment of knowledge...",
@@ -31,6 +33,7 @@
"settings_security": "Security Border Color",
"settings_ai_accent": "AI Accent Color",
"settings_ai_enable": "Enable AI Features",
"settings_category_enable": "Enable Category Feature (Advanced)",
"settings_lang": "Language",
"settings_save": "Save Settings",
"settings_reset": "Reset",
@@ -90,6 +93,12 @@
"label_group_explorer": "📁 Group Explorer",
"label_tag_explorer": "🏷️ Tag Explorer",
"label_last_updated": "Last updated: ",
"label_category_done": "Done",
"label_no_category": "No Category",
"tooltip_add_category": "Add/Edit Category",
"prompt_category_name": "Enter category name (max 10 chars):",
"msg_confirm_delete_category": "Delete this category?",
"msg_category_limit": "Maximum 3 categories can be pinned.",
"shortcuts_label": "⌨️ Shortcuts",
"shortcut_save": "Save",
@@ -109,7 +118,9 @@
"h2": "Heading 2",
"h3": "Heading 3",
"ai_summary": "AI Summary",
"ai_tags": "AI Tags"
"ai_tags": "AI Tags",
"shortcut_cat_3": "Alt+4: %s",
"shortcut_cat_clear": "Alt+5: Clear Category"
},
"calendar_months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
+11
View File
@@ -11,6 +11,8 @@
"nav_logout": "로그아웃",
"nav_settings": "환경 설정",
"nav_toggle": "사이드바 토글",
"nav_categories": "카테고리",
"nav_category_manage": "카테고리 관리",
"search_placeholder": "메모 검색... (제목, 내용, 태그)",
"composer_placeholder": "지식의 파편을 남겨주세요...",
@@ -31,6 +33,7 @@
"settings_security": "보안 테두리색",
"settings_ai_accent": "AI 분석 강조색",
"settings_ai_enable": "AI 기능 활성화",
"settings_category_enable": "카테고리 기능 활성화 (고급)",
"settings_lang": "언어 설정",
"settings_save": "저장",
"settings_reset": "초기화",
@@ -89,6 +92,12 @@
"label_group_explorer": "📁 그룹별 탐색",
"label_tag_explorer": "🏷️ 태그별 탐색",
"label_last_updated": "마지막 수정: ",
"label_category_done": "완료",
"label_no_category": "카테고리 없음",
"tooltip_add_category": "카테고리 추가/편집",
"prompt_category_name": "새 카테고리 이름을 입력하세요 (최대 10자):",
"msg_confirm_delete_category": "이 카테고리를 삭제할까요?",
"msg_category_limit": "카테고리는 최대 3개까지만 핀(Pin) 고정 가능합니다.",
"shortcuts_label": "⌨️ 단축키",
"shortcut_save": "저장",
@@ -96,6 +105,8 @@
"shortcut_nebula": "네뷸라",
"shortcut_slash": "슬래시 명령",
"shortcut_edit": "즉시 수정",
"shortcut_cat_3": "Alt+4: %s",
"shortcut_cat_clear": "Alt+5: 분류 해제",
"slash": {
"task": "체크박스",