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:
+24
-9
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
|
||||
/**
|
||||
* 전역 파일 다운로드 함수 (항상 전역 스코프 유지)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user