mirror of
https://github.com/sotam0316/brain_dogfood.git
synced 2026-04-25 03:48:38 +09:00
Initial Global Release v1.0 (Localization & Security Hardening)
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 첨부파일 영역 및 파일 칩 UI 컴포넌트
|
||||
*/
|
||||
import { escapeHTML } from '../utils.js';
|
||||
|
||||
/**
|
||||
* 파일 확장자에 따른 아이콘 반환
|
||||
*/
|
||||
export function getFileIcon(mime) {
|
||||
if (!mime) return '📎';
|
||||
mime = mime.toLowerCase();
|
||||
if (mime.includes('image')) return '🖼️';
|
||||
if (mime.includes('pdf')) return '📕';
|
||||
if (mime.includes('word') || mime.includes('text')) return '📄';
|
||||
if (mime.includes('zip') || mime.includes('compressed')) return '📦';
|
||||
return '📎';
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 영역 HTML 생성
|
||||
*/
|
||||
export function renderAttachmentBox(attachments) {
|
||||
if (!attachments || attachments.length === 0) return '';
|
||||
|
||||
let html = '<div class="memo-attachments">';
|
||||
attachments.forEach(a => {
|
||||
const icon = getFileIcon(a.file_type || '');
|
||||
html += `
|
||||
<a href="javascript:void(0)"
|
||||
class="file-chip"
|
||||
title="${escapeHTML(a.original_name)}"
|
||||
onclick="event.stopPropagation(); window.downloadFile('${a.filename}', '${escapeHTML(a.original_name)}')">
|
||||
<span class="icon">${icon}</span>
|
||||
<span class="name">${escapeHTML(a.original_name)}</span>
|
||||
</a>`;
|
||||
});
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
|
||||
/**
|
||||
* 사이드바 미니 캘린더 관리 모듈
|
||||
*/
|
||||
export const CalendarManager = {
|
||||
currentDate: new Date(),
|
||||
selectedDate: null,
|
||||
onDateSelect: null,
|
||||
memoDates: new Set(), // 메모가 있는 날짜들 (YYYY-MM-DD 형식)
|
||||
container: null,
|
||||
isCollapsed: false,
|
||||
|
||||
init(containerId, onDateSelect) {
|
||||
this.container = document.getElementById(containerId);
|
||||
this.onDateSelect = onDateSelect;
|
||||
|
||||
// 브라우저 저장소에서 접힘 상태 복구
|
||||
this.isCollapsed = localStorage.getItem('calendar_collapsed') === 'true';
|
||||
|
||||
this.bindEvents(); // 이벤트 먼저 바인딩
|
||||
this.updateCollapseUI();
|
||||
this.render();
|
||||
},
|
||||
|
||||
updateMemoDates(memos) {
|
||||
this.memoDates.clear();
|
||||
memos.forEach(memo => {
|
||||
if (memo.created_at) {
|
||||
const dateStr = memo.created_at.split('T')[0];
|
||||
this.memoDates.add(dateStr);
|
||||
}
|
||||
});
|
||||
this.render();
|
||||
},
|
||||
|
||||
bindEvents() {
|
||||
const header = document.getElementById('calendarHeader');
|
||||
if (header) {
|
||||
console.log('[Calendar] Binding events to header:', header);
|
||||
|
||||
const handleToggle = (e) => {
|
||||
console.log('[Calendar] Header clicked!', e.target);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 시각적 피드백: 클릭 시 잠시 배경색 변경
|
||||
const originalBg = header.style.background;
|
||||
header.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
setTimeout(() => { header.style.background = originalBg; }, 100);
|
||||
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
localStorage.setItem('calendar_collapsed', this.isCollapsed);
|
||||
this.updateCollapseUI();
|
||||
};
|
||||
|
||||
header.addEventListener('click', handleToggle, { capture: true });
|
||||
// 모바일 터치 대응을 위해 mousedown도 추가 (일부 브라우저 클릭 지연 방지)
|
||||
header.addEventListener('mousedown', (e) => console.log('[Calendar] Mousedown detected'), { capture: true });
|
||||
} else {
|
||||
console.error('[Calendar] Failed to find calendarHeader element!');
|
||||
}
|
||||
},
|
||||
|
||||
updateCollapseUI() {
|
||||
const content = document.getElementById('calendarContainer');
|
||||
const icon = document.getElementById('calendarToggleIcon');
|
||||
|
||||
if (content) {
|
||||
if (this.isCollapsed) {
|
||||
content.classList.add('collapsed');
|
||||
if (icon) icon.innerText = '▼';
|
||||
} else {
|
||||
content.classList.remove('collapsed');
|
||||
if (icon) icon.innerText = '▲';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
if (!this.container) return;
|
||||
|
||||
const year = this.currentDate.getFullYear();
|
||||
const month = this.currentDate.getMonth();
|
||||
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const prevDaysInMonth = new Date(year, month, 0).getDate();
|
||||
|
||||
const monthNames = I18nManager.t('calendar_months');
|
||||
const dayLabels = I18nManager.t('calendar_days');
|
||||
|
||||
// 문화권에 맞는 날짜 포맷팅 (예: "April 2026" vs "2026년 4월")
|
||||
const monthYearHeader = I18nManager.t('date_month_year')
|
||||
.replace('{year}', year)
|
||||
.replace('{month}', monthNames[month]);
|
||||
|
||||
let html = `
|
||||
<div class="calendar-widget glass-panel">
|
||||
<div class="calendar-nav">
|
||||
<button id="prevMonth"><</button>
|
||||
<span>${monthYearHeader}</span>
|
||||
<button id="nextMonth">></button>
|
||||
</div>
|
||||
<div class="calendar-grid">
|
||||
${dayLabels.map(day => `<div class="calendar-day-label">${day}</div>`).join('')}
|
||||
`;
|
||||
|
||||
// 이전 달 날짜들
|
||||
for (let i = firstDay - 1; i >= 0; i--) {
|
||||
html += `<div class="calendar-day other-month">${prevDaysInMonth - i}</div>`;
|
||||
}
|
||||
|
||||
// 현재 달 날짜들
|
||||
const today = new Date();
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const isToday = today.getFullYear() === year && today.getMonth() === month && today.getDate() === day;
|
||||
const isSelected = this.selectedDate === dateStr;
|
||||
const hasMemo = this.memoDates.has(dateStr);
|
||||
|
||||
html += `
|
||||
<div class="calendar-day ${isToday ? 'today' : ''} ${isSelected ? 'selected' : ''}" data-date="${dateStr}">
|
||||
${day}
|
||||
${hasMemo ? '<span class="activity-dot"></span>' : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `</div></div>`;
|
||||
this.container.innerHTML = html;
|
||||
|
||||
// 이벤트 바인딩
|
||||
this.container.querySelector('#prevMonth').onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
|
||||
this.render();
|
||||
};
|
||||
this.container.querySelector('#nextMonth').onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
|
||||
this.render();
|
||||
};
|
||||
|
||||
this.container.querySelectorAll('.calendar-day[data-date]').forEach(el => {
|
||||
el.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const date = el.dataset.date;
|
||||
if (this.selectedDate === date) {
|
||||
this.selectedDate = null; // 선택 해제
|
||||
} else {
|
||||
this.selectedDate = date;
|
||||
}
|
||||
this.render();
|
||||
if (this.onDateSelect) this.onDateSelect(this.selectedDate);
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 메모 작성 및 수정기 (Composer) 관리 모듈
|
||||
*/
|
||||
import { API } from '../api.js';
|
||||
import { EditorManager } from '../editor.js';
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
import { Constants } from '../utils/Constants.js';
|
||||
|
||||
export const ComposerManager = {
|
||||
DOM: {},
|
||||
|
||||
init(onSaveSuccess) {
|
||||
// 타이밍 이슈 방지를 위해 DOM 요소 지연 할당
|
||||
this.DOM = {
|
||||
trigger: document.getElementById('composerTrigger'),
|
||||
composer: document.getElementById('composer'),
|
||||
title: document.getElementById('memoTitle'),
|
||||
group: document.getElementById('memoGroup'),
|
||||
tags: document.getElementById('memoTags'),
|
||||
id: document.getElementById('editingMemoId'),
|
||||
encryptionToggle: document.getElementById('encryptionToggle'),
|
||||
password: document.getElementById('memoPassword'),
|
||||
foldBtn: document.getElementById('foldBtn'),
|
||||
discardBtn: document.getElementById('discardBtn')
|
||||
};
|
||||
|
||||
if (!this.DOM.composer || !this.DOM.trigger) return;
|
||||
|
||||
// 1. 이벤트 바인딩
|
||||
this.DOM.trigger.onclick = () => this.openEmpty();
|
||||
this.DOM.foldBtn.onclick = () => this.close();
|
||||
|
||||
this.DOM.discardBtn.onclick = async () => {
|
||||
if (confirm(I18nManager.t('msg_confirm_discard'))) {
|
||||
await EditorManager.cleanupSessionFiles();
|
||||
this.clear();
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
this.DOM.composer.onsubmit = (e) => {
|
||||
e.preventDefault();
|
||||
this.handleSave(onSaveSuccess);
|
||||
};
|
||||
|
||||
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} ▲`;
|
||||
};
|
||||
}
|
||||
|
||||
// --- 자동 임시저장 (Auto-Draft) ---
|
||||
this.draftTimer = setInterval(() => this.saveDraft(), 3000);
|
||||
this.checkDraftRestore();
|
||||
},
|
||||
|
||||
openEmpty() {
|
||||
this.clear();
|
||||
this.DOM.composer.style.display = 'block';
|
||||
this.DOM.trigger.style.display = 'none';
|
||||
this.DOM.title.focus();
|
||||
},
|
||||
|
||||
openForEdit(memo) {
|
||||
if (!memo) return;
|
||||
this.clear();
|
||||
this.DOM.id.value = memo.id;
|
||||
this.DOM.title.value = memo.title || '';
|
||||
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(', ');
|
||||
|
||||
EditorManager.setMarkdown(memo.content || '');
|
||||
EditorManager.setAttachedFiles(memo.attachments || []);
|
||||
|
||||
if (memo.was_encrypted || memo.is_encrypted) {
|
||||
this.setLocked(true, memo.tempPassword || '');
|
||||
}
|
||||
|
||||
this.DOM.composer.style.display = 'block';
|
||||
this.DOM.trigger.style.display = 'none';
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
async handleSave(callback) {
|
||||
const data = {
|
||||
title: this.DOM.title.value.trim(),
|
||||
content: EditorManager.getMarkdown(),
|
||||
group_name: this.DOM.group.value.trim() || Constants.GROUPS.DEFAULT,
|
||||
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(),
|
||||
attachment_filenames: EditorManager.getAttachedFilenames()
|
||||
};
|
||||
|
||||
if (!data.title && !data.content) { this.close(); return; }
|
||||
if (data.is_encrypted && !data.password) { alert(I18nManager.t('msg_alert_password_required')); return; }
|
||||
|
||||
try {
|
||||
await API.saveMemo(data, this.DOM.id.value);
|
||||
EditorManager.sessionFiles.clear();
|
||||
this.clearDraft();
|
||||
if (callback) await callback();
|
||||
this.clear();
|
||||
this.close();
|
||||
} catch (err) { alert(err.message); }
|
||||
},
|
||||
|
||||
close() {
|
||||
this.DOM.composer.style.display = 'none';
|
||||
this.DOM.trigger.style.display = 'block';
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.DOM.id.value = '';
|
||||
this.DOM.title.value = '';
|
||||
this.DOM.group.value = Constants.GROUPS.DEFAULT;
|
||||
this.DOM.tags.value = '';
|
||||
EditorManager.setMarkdown('');
|
||||
EditorManager.setAttachedFiles([]);
|
||||
this.setLocked(false);
|
||||
},
|
||||
|
||||
toggleEncryption() {
|
||||
const isLocked = this.DOM.encryptionToggle.dataset.locked === 'true';
|
||||
this.setLocked(!isLocked);
|
||||
},
|
||||
|
||||
setLocked(locked, password = null) {
|
||||
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();
|
||||
}
|
||||
},
|
||||
|
||||
// === 자동 임시저장 (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));
|
||||
},
|
||||
|
||||
/**
|
||||
* 페이지 로드 시 임시저장된 내용이 있으면 복원 확인
|
||||
*/
|
||||
checkDraftRestore() {
|
||||
const raw = localStorage.getItem('memo_draft');
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const draft = JSON.parse(raw);
|
||||
|
||||
// 24시간 이상 된 임시저장은 자동 삭제
|
||||
if (Date.now() - draft.timestamp > 86400000) {
|
||||
this.clearDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
// 내용이 실제로 있는 경우에만 복원 확인
|
||||
if (!draft.title && !draft.content) {
|
||||
this.clearDraft();
|
||||
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();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Draft] Failed to parse draft, deleting:', e);
|
||||
this.clearDraft();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 임시저장 데이터 삭제
|
||||
*/
|
||||
clearDraft() {
|
||||
localStorage.removeItem('memo_draft');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 지식 탐색 서랍(Drawer) 관리 모듈
|
||||
*/
|
||||
import { escapeHTML } from '../utils.js';
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
import { Constants } from '../utils/Constants.js';
|
||||
|
||||
export const DrawerManager = {
|
||||
DOM: {},
|
||||
|
||||
init() {
|
||||
this.DOM.drawer = document.getElementById('knowledgeDrawer');
|
||||
this.DOM.drawerContent = document.getElementById('drawerContent');
|
||||
const header = this.DOM.drawer?.querySelector('.drawer-header');
|
||||
|
||||
if (!this.DOM.drawer || !header) return;
|
||||
|
||||
// 닫기 버튼 이벤트
|
||||
const closeBtn = document.getElementById('closeDrawerBtn');
|
||||
if (closeBtn) {
|
||||
closeBtn.onclick = () => this.close();
|
||||
}
|
||||
|
||||
// --- 드래그 앤 드롭 로직 구현 ---
|
||||
let isDragging = false;
|
||||
let offset = { x: 0, y: 0 };
|
||||
|
||||
header.addEventListener('mousedown', (e) => {
|
||||
if (e.target.closest('.close-btn')) return; // 닫기 버튼 클릭 시 드래그 방지
|
||||
|
||||
isDragging = true;
|
||||
this.DOM.drawer.classList.add('dragging');
|
||||
|
||||
// 마우스 클릭 위치와 요소 좌상단 사이의 거리 계산
|
||||
const rect = this.DOM.drawer.getBoundingClientRect();
|
||||
offset.x = e.clientX - rect.left;
|
||||
offset.y = e.clientY - rect.top;
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// 새로운 위치 계산
|
||||
let left = e.clientX - offset.x;
|
||||
let top = e.clientY - offset.y;
|
||||
|
||||
// 화면 경계 이탈 방지
|
||||
const winW = window.innerWidth;
|
||||
const winH = window.innerHeight;
|
||||
const cardW = this.DOM.drawer.offsetWidth;
|
||||
const cardH = this.DOM.drawer.offsetHeight;
|
||||
|
||||
left = Math.max(0, Math.min(left, winW - cardW));
|
||||
top = Math.max(0, Math.min(top, winH - cardH));
|
||||
|
||||
this.DOM.drawer.style.left = `${left}px`;
|
||||
this.DOM.drawer.style.top = `${top}px`;
|
||||
this.DOM.drawer.style.bottom = 'auto'; // bottom 제거
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
isDragging = false;
|
||||
this.DOM.drawer?.classList.remove('dragging');
|
||||
});
|
||||
},
|
||||
|
||||
open(memos = [], activeFilter, onFilterCallback) {
|
||||
if (!this.DOM.drawer || !this.DOM.drawerContent) return;
|
||||
|
||||
// 0. 데이터 유효성 검사
|
||||
if (!memos || memos.length === 0) {
|
||||
this.DOM.drawerContent.innerHTML = `<p style="color:var(--muted); text-align:center; padding:20px;">${I18nManager.t('label_no_results')}</p>`;
|
||||
this.DOM.drawer.classList.add('active');
|
||||
return;
|
||||
}
|
||||
// 1. 그룹 및 태그 카운트 계산
|
||||
const groupAllKey = 'all';
|
||||
const groupCounts = { [groupAllKey]: memos.length };
|
||||
const tagCounts = {};
|
||||
const tagsSourceMap = new Map();
|
||||
|
||||
memos.forEach(m => {
|
||||
const g = m.group_name || Constants.GROUPS.DEFAULT;
|
||||
groupCounts[g] = (groupCounts[g] || 0) + 1;
|
||||
|
||||
if (m.tags) {
|
||||
m.tags.forEach(t => {
|
||||
tagCounts[t.name] = (tagCounts[t.name] || 0) + 1;
|
||||
const current = tagsSourceMap.get(t.name);
|
||||
if (!current || t.source === 'user') tagsSourceMap.set(t.name, t.source);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sortedGroups = Object.keys(groupCounts).filter(g => g !== groupAllKey).sort();
|
||||
const sortedTags = Object.keys(tagCounts).sort().map(tn => ({
|
||||
name: tn,
|
||||
source: tagsSourceMap.get(tn),
|
||||
count: tagCounts[tn]
|
||||
}));
|
||||
|
||||
// 2. HTML 렌더링
|
||||
let html = `
|
||||
<div class="explorer-section">
|
||||
<h3>${I18nManager.t('drawer_title_groups')}</h3>
|
||||
<div class="explorer-grid">
|
||||
<div class="explorer-chip ${activeFilter === 'all' ? 'active' : ''}" data-filter="all">
|
||||
💡 ${I18nManager.t('nav_all')} <span class="chip-count">${groupCounts[groupAllKey]}</span>
|
||||
</div>
|
||||
${sortedGroups.map(g => `
|
||||
<div class="explorer-chip ${activeFilter === g ? 'active' : ''}" data-filter="${escapeHTML(g)}">
|
||||
📁 ${escapeHTML(g)} <span class="chip-count">${groupCounts[g]}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="explorer-section" style="margin-top:20px;">
|
||||
<h3>${I18nManager.t('drawer_title_tags')}</h3>
|
||||
<div class="explorer-grid">
|
||||
${sortedTags.map(t => `
|
||||
<div class="explorer-chip ${t.source === 'ai' ? 'tag-ai' : 'tag-user'} ${activeFilter === `tag:${t.source}:${t.name}` ? 'active' : ''}"
|
||||
data-filter="tag:${t.source}:${escapeHTML(t.name)}">
|
||||
${t.source === 'ai' ? '🪄' : '🏷️'} ${escapeHTML(t.name)} <span class="chip-count">${t.count}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.DOM.drawerContent.innerHTML = html;
|
||||
this.DOM.drawer.classList.add('active');
|
||||
|
||||
// 3. 이벤트 바인딩
|
||||
this.DOM.drawerContent.querySelectorAll('.explorer-chip').forEach(chip => {
|
||||
chip.onclick = () => {
|
||||
const filter = chip.dataset.filter;
|
||||
onFilterCallback(filter);
|
||||
// 선택 시 서랍을 닫을지 유지할지는 UX 선택 (일단 닫음)
|
||||
// this.close();
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
close() {
|
||||
if (this.DOM.drawer) {
|
||||
this.DOM.drawer.classList.remove('active');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
|
||||
/**
|
||||
* 지식 성장 히트맵(Heatmap) 관리 모듈
|
||||
* 최근 지정된 기간(기본 365일) 동안의 메모 작성 활동량을 시각화합니다.
|
||||
*/
|
||||
export const HeatmapManager = {
|
||||
container: null,
|
||||
data: [], // [{date: 'YYYY-MM-DD', count: N}, ...]
|
||||
currentRange: 365, // 기본 365일
|
||||
|
||||
init(containerId) {
|
||||
this.container = document.getElementById(containerId);
|
||||
if (!this.container) {
|
||||
console.warn('[Heatmap] Container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 로컬스토리지에서 이전에 선택한 범위 복구
|
||||
const savedRange = localStorage.getItem('heatmap_range');
|
||||
if (savedRange) {
|
||||
this.currentRange = parseInt(savedRange, 10);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 데이터를 서버에서 가져와 렌더링합니다.
|
||||
*/
|
||||
async refresh() {
|
||||
try {
|
||||
const { API } = await import('../api.js');
|
||||
this.data = await API.fetchHeatmapData(this.currentRange);
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('[Heatmap] Failed to fetch stats:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 히트맵 그리드를 생성합니다.
|
||||
*/
|
||||
render() {
|
||||
if (!this.container) return;
|
||||
|
||||
const dataMap = new Map(this.data.map(d => [d.date, d.count]));
|
||||
|
||||
// 날짜 계산
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - (this.currentRange - 1));
|
||||
|
||||
// 요일 맞추기 (일요일 시작 기준)
|
||||
const dayOfWeek = startDate.getDay();
|
||||
const adjustedStartDate = new Date(startDate);
|
||||
adjustedStartDate.setDate(startDate.getDate() - dayOfWeek);
|
||||
|
||||
const rangeLabel = I18nManager.t(`heatmap_ranges.${this.currentRange}`) || I18nManager.t('label_select_range');
|
||||
|
||||
const heatmapTitle = I18nManager.t('label_heatmap_title');
|
||||
const rangeOptions = I18nManager.t('heatmap_ranges');
|
||||
const labelLess = I18nManager.t('label_less');
|
||||
const labelMore = I18nManager.t('label_more');
|
||||
|
||||
let html = `
|
||||
<div class="heatmap-wrapper glass-panel">
|
||||
<div class="heatmap-header">
|
||||
<span class="heatmap-title">${heatmapTitle}</span>
|
||||
<select id="heatmapRangeSelect" class="heatmap-select">
|
||||
${Object.entries(rangeOptions).map(([val, label]) => `
|
||||
<option value="${val}" ${this.currentRange.toString() === val ? 'selected' : ''}>${label}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="heatmap-grid" id="heatmapGrid">
|
||||
`;
|
||||
|
||||
const formatDate = (date) => {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
// 전체 표시 일수 (범위 + 요일 보정)
|
||||
const totalCells = this.currentRange + dayOfWeek + (6 - today.getDay());
|
||||
|
||||
for (let i = 0; i < totalCells; i++) {
|
||||
const currentDate = new Date(adjustedStartDate);
|
||||
currentDate.setDate(adjustedStartDate.getDate() + i);
|
||||
|
||||
const dateStr = formatDate(currentDate);
|
||||
const count = dataMap.get(dateStr) || 0;
|
||||
const level = this.calculateLevel(count);
|
||||
|
||||
const isOutOfRange = currentDate < startDate || currentDate > today;
|
||||
|
||||
const tooltip = I18nManager.t('tooltip_heatmap_stat')
|
||||
.replace('{date}', dateStr)
|
||||
.replace('{count}', count);
|
||||
|
||||
html += `
|
||||
<div class="heatmap-cell ${isOutOfRange ? 'out' : `lvl-${level}`}"
|
||||
data-date="${dateStr}"
|
||||
data-count="${count}"
|
||||
title="${tooltip}">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
<div class="heatmap-legend">
|
||||
<span>${labelLess}</span>
|
||||
<div class="heatmap-cell lvl-0"></div>
|
||||
<div class="heatmap-cell lvl-1"></div>
|
||||
<div class="heatmap-cell lvl-2"></div>
|
||||
<div class="heatmap-cell lvl-3"></div>
|
||||
<div class="heatmap-cell lvl-4"></div>
|
||||
<span>${labelMore}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.container.innerHTML = html;
|
||||
this.bindEvents();
|
||||
},
|
||||
|
||||
calculateLevel(count) {
|
||||
if (count === 0) return 0;
|
||||
if (count <= 1) return 1;
|
||||
if (count <= 3) return 2;
|
||||
if (count <= 5) return 3;
|
||||
return 4;
|
||||
},
|
||||
|
||||
bindEvents() {
|
||||
const select = this.container.querySelector('#heatmapRangeSelect');
|
||||
if (select) {
|
||||
select.onchange = (e) => {
|
||||
this.currentRange = parseInt(e.target.value, 10);
|
||||
localStorage.setItem('heatmap_range', this.currentRange);
|
||||
this.refresh();
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 메모 카드 컴포넌트
|
||||
*/
|
||||
import { escapeHTML, parseInternalLinks, fixImagePaths } from '../utils.js';
|
||||
import { renderAttachmentBox } from './AttachmentBox.js';
|
||||
import { Constants } from '../utils/Constants.js';
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
|
||||
/**
|
||||
* 단일 메모 카드의 HTML 생성을 전담합니다.
|
||||
*/
|
||||
export function createMemoCardHtml(memo, isDone) {
|
||||
const cardClass = `memo-card ${isDone ? 'done' : ''} ${memo.is_encrypted ? 'encrypted' : ''} glass-panel`;
|
||||
const borderStyle = memo.color ? `style="border-left: 5px solid ${memo.color}"` : '';
|
||||
|
||||
let summaryHtml = '';
|
||||
if (memo.summary) {
|
||||
// 암호화된 메모가 잠긴 상태라면 AI 요약도 숨김 (정보 유출 방지)
|
||||
const isLocked = memo.is_encrypted && (!memo.content || memo.content.includes('encrypted-block') || typeof memo.is_encrypted === 'number');
|
||||
// 참고: app.js에서 해독 성공 시 memo.is_encrypted를 false로 바꿨으므로, is_encrypted가 true면 잠긴 상태임
|
||||
if (!memo.is_encrypted) {
|
||||
summaryHtml = `<div class="memo-summary"><strong>${I18nManager.t('label_ai_summary')}:</strong> ${escapeHTML(memo.summary)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
const titleHtml = memo.title ? `<h3 class="memo-title">${escapeHTML(memo.title)}</h3>` : '';
|
||||
|
||||
let htmlContent = '';
|
||||
if (!isDone) {
|
||||
if (memo.is_encrypted) {
|
||||
htmlContent = `
|
||||
<div class="encrypted-block" style="display:flex; align-items:center; gap:10px; padding:8px 12px; background:rgba(255,255,255,0.03); border-radius:8px; border:1px solid rgba(255,255,255,0.05);">
|
||||
<span style="font-size:1rem;">🔒</span>
|
||||
<span style="font-size:0.85rem; color:var(--muted); flex:1;">${I18nManager.t('msg_encrypted_locked')}</span>
|
||||
<button class="action-btn unlock-btn" data-id="${memo.id}" style="font-size:0.75rem; padding:4px 10px; background:var(--ai-accent);">${I18nManager.t('btn_unlock')}</button>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// marked로 파싱한 후 DOMPurify로 살균하여 XSS 방지
|
||||
htmlContent = DOMPurify.sanitize(marked.parse(memo.content || ''));
|
||||
htmlContent = parseInternalLinks(htmlContent);
|
||||
htmlContent = fixImagePaths(htmlContent);
|
||||
}
|
||||
}
|
||||
const contentHtml = `<div class="memo-content">${htmlContent}</div>`;
|
||||
|
||||
let metaHtml = '<div class="memo-meta">';
|
||||
if (!isDone && memo.group_name && memo.group_name !== Constants.GROUPS.DEFAULT) {
|
||||
const groupName = (Object.values(Constants.GROUPS).includes(memo.group_name))
|
||||
? I18nManager.t(`groups.${memo.group_name}`)
|
||||
: memo.group_name;
|
||||
metaHtml += `<span class="group-badge">📁 ${escapeHTML(groupName)}</span>`;
|
||||
}
|
||||
if (memo.tags && memo.tags.length > 0) {
|
||||
memo.tags.forEach(t => {
|
||||
// 암호화된 메모가 잠긴 상태일 때 AI 태그만 선택적으로 숨김
|
||||
if (memo.is_encrypted && t.source === 'ai') return;
|
||||
|
||||
const typeClass = t.source === 'ai' ? 'tag-ai' : 'tag-user';
|
||||
metaHtml += `<span class="tag-badge ${typeClass}">${t.source === 'ai' ? '🪄 ' : '#'}${escapeHTML(t.name)}</span>`;
|
||||
});
|
||||
}
|
||||
metaHtml += '</div>';
|
||||
|
||||
let linksHtml = '';
|
||||
if (!isDone && memo.backlinks && memo.backlinks.length > 0) {
|
||||
linksHtml = `<div class="memo-backlinks">🔗 ${I18nManager.t('label_mentioned')}: ` +
|
||||
memo.backlinks.map(l => `<span class="link-item" data-id="${l.id}">#${escapeHTML(l.title || l.id.toString())}</span>`).join(', ') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// 암호화된 메모인 경우 해독 전까지 첨부파일 목록 숨김
|
||||
const attachmentsHtml = !memo.is_encrypted ? renderAttachmentBox(memo.attachments) : '';
|
||||
|
||||
// 암호화된 메모가 잠긴 상태라면 하단 액션 버튼(수정, 삭제, AI 등)을 아예 보여주지 않음 (보안 및 UI 겹침 방지)
|
||||
const isLocked = memo.is_encrypted && (!htmlContent || htmlContent.includes('encrypted-block'));
|
||||
const actionsHtml = isLocked ? '' : `
|
||||
<div class="memo-actions">
|
||||
<button class="action-btn toggle-pin" data-id="${memo.id}" title="${I18nManager.t('title_pin')}">${memo.is_pinned ? '⭐' : '☆'}</button>
|
||||
<button class="action-btn toggle-status" data-id="${memo.id}" title="${isDone ? I18nManager.t('title_undo') : I18nManager.t('title_done')}">${isDone ? '↩️' : '✅'}</button>
|
||||
${!isDone ? `<button class="action-btn ai-btn" data-id="${memo.id}" title="${I18nManager.t('title_ai')}">🪄</button>` : ''}
|
||||
<button class="action-btn edit-btn" data-id="${memo.id}" title="${I18nManager.t('title_edit')}">✏️</button>
|
||||
<button class="action-btn delete-btn" data-id="${memo.id}" title="${I18nManager.t('title_delete')}">🗑️</button>
|
||||
</div>
|
||||
`;
|
||||
const idBadge = `<div style="position:absolute; top:10px; right:12px; color:rgba(255,255,255,0.15); font-size:10px; font-weight:900;">#${memo.id}</div>`;
|
||||
|
||||
return {
|
||||
className: cardClass,
|
||||
style: borderStyle,
|
||||
innerHtml: idBadge + summaryHtml + titleHtml + metaHtml + contentHtml + linksHtml + attachmentsHtml + actionsHtml
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 모달 창(메모 상세, 파일 라이브러리 등) 생성을 관리하는 모듈
|
||||
*/
|
||||
import { API } from '../api.js';
|
||||
import { escapeHTML } from '../utils.js';
|
||||
import { renderAttachmentBox } from './AttachmentBox.js';
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
import { Constants } from '../utils/Constants.js';
|
||||
|
||||
export const ModalManager = {
|
||||
// 타이밍 이슈 방지를 위해 lazy getter 패턴 적용
|
||||
getDOM() {
|
||||
return {
|
||||
modal: document.getElementById('memoModal'),
|
||||
modalContent: document.getElementById('modalContent'),
|
||||
loadingOverlay: document.getElementById('loadingOverlay'),
|
||||
explorerModal: document.getElementById('explorerModal'),
|
||||
explorerContent: document.getElementById('explorerContent')
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 전체 첨부파일 라이브러리(Asset Library) 모달 열기
|
||||
*/
|
||||
async openAssetLibrary(openMemoDetailsCallback) {
|
||||
const dom = this.getDOM();
|
||||
if (!dom.loadingOverlay) return;
|
||||
|
||||
dom.loadingOverlay.style.display = 'flex';
|
||||
try {
|
||||
const assets = await API.fetchAssets();
|
||||
let html = `
|
||||
<div style="padding:20px; position:relative;">
|
||||
<button class="close-modal-btn">×</button>
|
||||
<h2 style="margin-bottom:20px;">${I18nManager.t('label_asset_management')}</h2>
|
||||
<p style="font-size:0.8rem; color:var(--muted); margin-bottom:20px;">${I18nManager.t('label_asset_hint')}</p>
|
||||
<div style="display:grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap:15px;">
|
||||
${assets.length > 0 ? assets.map(a => `
|
||||
<div class="asset-card" data-memo-id="${a.memo_id}" data-url="/api/download/${a.filename}" style="background:rgba(255,255,255,0.05); padding:10px; border-radius:8px; cursor:pointer;">
|
||||
${['png','jpg','jpeg','gif','webp','svg'].includes(a.file_type?.toLowerCase())
|
||||
? `<img src="/api/download/${a.filename}" style="width:100%; height:120px; object-fit:cover; border-radius:4px; margin-bottom:8px;">`
|
||||
: `<div style="width:100%; height:120px; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.2); border-radius:4px; margin-bottom:8px; font-size:2rem;">📎</div>`
|
||||
}
|
||||
<div style="font-size:0.8rem; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${escapeHTML(a.original_name)}</div>
|
||||
<div style="font-size:0.7rem; color:var(--muted);">${a.memo_title ? `${I18nManager.t('label_memo_ref')}${escapeHTML(a.memo_title)}` : I18nManager.t('label_no_memo_ref')}</div>
|
||||
</div>
|
||||
`).join('') : `<div style="grid-column:1/-1; text-align:center; padding:40px; color:var(--muted);">${I18nManager.t('label_no_assets')}</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
dom.modalContent.innerHTML = html;
|
||||
dom.modal.classList.add('active');
|
||||
|
||||
// 닫기 버튼 이벤트
|
||||
dom.modalContent.querySelector('.close-modal-btn').onclick = () => {
|
||||
dom.modal.classList.remove('active');
|
||||
};
|
||||
|
||||
dom.modalContent.querySelectorAll('.asset-card').forEach(card => {
|
||||
card.onclick = (e) => {
|
||||
const url = card.dataset.url;
|
||||
const filename = url.split('/').pop();
|
||||
const originalName = card.querySelector('div').innerText;
|
||||
const memoId = card.dataset.memoId;
|
||||
|
||||
if (e.altKey) {
|
||||
e.stopPropagation();
|
||||
window.downloadFile(filename, originalName);
|
||||
} else if (memoId && memoId !== 'null') {
|
||||
dom.modal.classList.remove('active');
|
||||
openMemoDetailsCallback(memoId, window.allMemosCache);
|
||||
} else {
|
||||
window.downloadFile(filename, originalName);
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch (err) { alert(err.message); }
|
||||
finally { dom.loadingOverlay.style.display = 'none'; }
|
||||
},
|
||||
|
||||
/**
|
||||
* 지식 탐색기(Knowledge Explorer) 모달 열기
|
||||
*/
|
||||
openKnowledgeExplorer(memos, activeFilter, onFilterCallback) {
|
||||
const dom = this.getDOM();
|
||||
// 1. 그룹 및 태그 카운트 계산
|
||||
const groupAllKey = 'all';
|
||||
const groupCounts = { [groupAllKey]: memos.length };
|
||||
const tagCounts = {};
|
||||
const tagsSourceMap = new Map(); // 태그명 -> 소스 매핑
|
||||
|
||||
memos.forEach(m => {
|
||||
const g = m.group_name || Constants.GROUPS.DEFAULT;
|
||||
groupCounts[g] = (groupCounts[g] || 0) + 1;
|
||||
|
||||
if (m.tags) {
|
||||
m.tags.forEach(t => {
|
||||
tagCounts[t.name] = (tagCounts[t.name] || 0) + 1;
|
||||
const current = tagsSourceMap.get(t.name);
|
||||
if (!current || t.source === 'user') tagsSourceMap.set(t.name, t.source);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sortedGroups = Object.keys(groupCounts)
|
||||
.filter(g => g !== groupAllKey)
|
||||
.sort((a,b) => a === Constants.GROUPS.DEFAULT ? -1 : b === Constants.GROUPS.DEFAULT ? 1 : a.localeCompare(b));
|
||||
|
||||
const sortedTags = Object.keys(tagCounts).sort().map(tn => ({
|
||||
name: tn,
|
||||
source: tagsSourceMap.get(tn),
|
||||
count: tagCounts[tn]
|
||||
}));
|
||||
|
||||
let html = `
|
||||
<div class="explorer-section">
|
||||
<h3 style="margin-bottom:15px; color:var(--accent);">${I18nManager.t('label_group_explorer')}</h3>
|
||||
<div class="explorer-grid">
|
||||
<div class="explorer-chip ${activeFilter === 'all' ? 'active' : ''}" data-filter="all">
|
||||
💡 ${I18nManager.t('nav_all')} <span class="chip-count">${groupCounts[groupAllKey]}</span>
|
||||
</div>
|
||||
${sortedGroups.map(g => `
|
||||
<div class="explorer-chip ${activeFilter === g ? 'active' : ''}" data-filter="${escapeHTML(g)}">
|
||||
📁 ${escapeHTML(g)} <span class="chip-count">${groupCounts[g]}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="explorer-section" style="margin-top:30px;">
|
||||
<h3 style="margin-bottom:15px; color:var(--ai-accent);">${I18nManager.t('label_tag_explorer')}</h3>
|
||||
<div class="explorer-grid">
|
||||
${sortedTags.map(t => `
|
||||
<div class="explorer-chip tag-chip ${activeFilter === `tag:${t.source}:${t.name}` ? 'active' : ''}"
|
||||
data-filter="tag:${t.source}:${escapeHTML(t.name)}">
|
||||
${t.source === 'ai' ? '🪄' : '🏷️'} ${escapeHTML(t.name)} <span class="chip-count">${t.count}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
dom.explorerContent.innerHTML = html;
|
||||
dom.explorerModal.classList.add('active');
|
||||
|
||||
// 이벤트 바인딩
|
||||
const closeBtn = dom.explorerModal.querySelector('.close-explorer-btn');
|
||||
closeBtn.onclick = () => dom.explorerModal.classList.remove('active');
|
||||
|
||||
dom.explorerContent.querySelectorAll('.explorer-chip').forEach(chip => {
|
||||
chip.onclick = () => {
|
||||
const filter = chip.dataset.filter;
|
||||
onFilterCallback(filter);
|
||||
dom.explorerModal.classList.remove('active');
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 개별 메모 상세 모달 열기
|
||||
*/
|
||||
openMemoModal(id, memos) {
|
||||
const dom = this.getDOM();
|
||||
const memo = memos.find(m => m.id == id);
|
||||
if (!memo) return;
|
||||
|
||||
import('../utils.js').then(({ parseInternalLinks, fixImagePaths }) => {
|
||||
// 마크다운 파싱 후 살균 처리 (marked, DOMPurify는 global 사용)
|
||||
let html = DOMPurify.sanitize(marked.parse(memo.content));
|
||||
html = parseInternalLinks(html);
|
||||
html = fixImagePaths(html);
|
||||
|
||||
const lastUpdatedTime = new Date(memo.updated_at).toLocaleString();
|
||||
|
||||
dom.modalContent.innerHTML = `
|
||||
<button class="close-modal-btn">×</button>
|
||||
${memo.title ? `<h2 style="margin-bottom:10px;">${escapeHTML(memo.title)}</h2>` : ''}
|
||||
|
||||
${memo.summary ? `
|
||||
<div class="ai-summary-box" style="margin: 15px 0 25px 0; padding: 15px; background: rgba(56, 189, 248, 0.1); border-left: 4px solid var(--accent); border-radius: 8px; position: relative; overflow: hidden;">
|
||||
<div style="font-size: 0.7rem; color: var(--accent); font-weight: 800; margin-bottom: 8px; display: flex; align-items: center; gap: 5px; letter-spacing: 0.05em;">
|
||||
<span>🪄 AI INSIGHT</span>
|
||||
</div>
|
||||
<div style="font-size: 0.95rem; line-height: 1.6; color: #e2e8f0; font-weight: 400;">${escapeHTML(memo.summary)}</div>
|
||||
</div>
|
||||
` : '<hr style="margin:15px 0; opacity:0.1">'}
|
||||
|
||||
<div class="memo-content">${html}</div>
|
||||
<div style="margin-top:20px; font-size:0.8rem; color:var(--muted)">${I18nManager.t('label_last_updated')}${lastUpdatedTime}</div>
|
||||
`;
|
||||
|
||||
// 닫기 버튼 이벤트
|
||||
const closeBtn = dom.modalContent.querySelector('.close-modal-btn');
|
||||
if (closeBtn) {
|
||||
closeBtn.onclick = () => dom.modal.classList.remove('active');
|
||||
}
|
||||
|
||||
const attachmentsHtml = renderAttachmentBox(memo.attachments);
|
||||
if (attachmentsHtml) {
|
||||
const footer = document.createElement('div');
|
||||
footer.style.cssText = 'margin-top:30px; padding-top:15px; border-top:1px solid rgba(255,255,255,0.05);';
|
||||
footer.innerHTML = attachmentsHtml;
|
||||
dom.modalContent.appendChild(footer);
|
||||
}
|
||||
|
||||
dom.modal.classList.add('active');
|
||||
dom.modalContent.querySelectorAll('.internal-link').forEach(l => {
|
||||
l.onclick = () => this.openMemoModal(l.dataset.id, memos);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 사이드바 그룹 목록 컴포넌트
|
||||
*/
|
||||
import { escapeHTML } from '../utils.js';
|
||||
import { Constants } from '../utils/Constants.js';
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
|
||||
/**
|
||||
* 그룹 목록 HTML 렌더링
|
||||
*/
|
||||
export function renderGroupList(container, groups, activeGroup, onGroupClick) {
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
groups.forEach(group => {
|
||||
const li = document.createElement('li');
|
||||
const isActive = group === activeGroup || (group === Constants.GROUPS.DEFAULT && activeGroup === 'all');
|
||||
li.className = isActive ? 'active' : '';
|
||||
|
||||
// 아이콘 선택 및 클래스 추가
|
||||
let icon = '📁';
|
||||
if (group === Constants.GROUPS.DEFAULT || group === 'all') icon = '💡';
|
||||
else if (group === Constants.GROUPS.FILES) icon = '📂';
|
||||
else if (group === Constants.GROUPS.DONE) icon = '✅';
|
||||
else if (group.startsWith('tag:')) {
|
||||
const parts = group.split(':'); // tag:source:name
|
||||
const source = parts[1];
|
||||
icon = source === 'ai' ? '🪄' : '🏷️';
|
||||
li.classList.add(source === 'ai' ? 'tag-ai' : 'tag-user');
|
||||
}
|
||||
|
||||
// 표시 이름 결정
|
||||
let label = group;
|
||||
if (group === 'all') label = I18nManager.t('groups.all');
|
||||
else if (group === Constants.GROUPS.DEFAULT) label = I18nManager.t('groups.default');
|
||||
else if (group === Constants.GROUPS.FILES) label = I18nManager.t('groups.files');
|
||||
else if (group === Constants.GROUPS.DONE) label = I18nManager.t('groups.done');
|
||||
else if (group.startsWith('tag:')) {
|
||||
const parts = group.split(':');
|
||||
label = parts[2]; // 태그 이름
|
||||
}
|
||||
|
||||
li.innerHTML = `<span class="icon">${icon}</span> <span class="text">${escapeHTML(label)}</span>`;
|
||||
li.onclick = () => onGroupClick(group);
|
||||
container.appendChild(li);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
|
||||
export const SlashCommand = {
|
||||
// 사용 가능한 명령 목록
|
||||
commands: [
|
||||
{ icon: '☑️', label: I18nManager.t('slash.task'), cmd: 'taskList' },
|
||||
{ icon: '•', label: I18nManager.t('slash.bullet'), cmd: 'bulletList' },
|
||||
{ icon: '1.', label: I18nManager.t('slash.number'), cmd: 'orderedList' },
|
||||
{ icon: '❝', label: I18nManager.t('slash.quote'), cmd: 'blockQuote' },
|
||||
{ icon: '—', label: I18nManager.t('slash.line'), cmd: 'thematicBreak' },
|
||||
{ icon: '{}', label: I18nManager.t('slash.code'), cmd: 'codeBlock' },
|
||||
{ icon: 'H1', label: I18nManager.t('slash.h1'), cmd: 'heading', payload: { level: 1 } },
|
||||
{ icon: 'H2', label: I18nManager.t('slash.h2'), cmd: 'heading', payload: { level: 2 } },
|
||||
{ icon: 'H3', label: I18nManager.t('slash.h3'), cmd: 'heading', payload: { level: 3 } },
|
||||
{ icon: '🪄', label: I18nManager.t('slash.ai_summary'), cmd: 'ai-summary', isAI: true },
|
||||
{ icon: '🏷️', label: I18nManager.t('slash.ai_tags'), cmd: 'ai-tags', isAI: true },
|
||||
],
|
||||
|
||||
popupEl: null,
|
||||
selectedIndex: 0,
|
||||
isOpen: false,
|
||||
editorRef: null,
|
||||
editorElRef: null,
|
||||
filterText: '', // '/' 이후 입력된 필터 텍스트
|
||||
filteredCommands: [], // 필터링된 명령 목록
|
||||
|
||||
/**
|
||||
* 초기화: 팝업 DOM 생성 및 이벤트 바인딩
|
||||
*/
|
||||
init(editor, editorEl) {
|
||||
this.editorRef = editor;
|
||||
this.editorElRef = editorEl;
|
||||
console.log('[SlashCmd] init 호출됨, editor:', !!editor, 'editorEl:', !!editorEl);
|
||||
|
||||
// 팝업 컨테이너 생성
|
||||
this.popupEl = document.createElement('div');
|
||||
this.popupEl.id = 'slashCommandPopup';
|
||||
this.popupEl.className = 'slash-popup';
|
||||
this.popupEl.style.display = 'none';
|
||||
document.body.appendChild(this.popupEl);
|
||||
|
||||
// 에디터 keydown 이벤트 (팝업 열린 상태에서 네비게이션 가로채기)
|
||||
editorEl.addEventListener('keydown', (e) => {
|
||||
if (!this.isOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.navigate(1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.navigate(-1);
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.executeSelected();
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.hide();
|
||||
break;
|
||||
case 'Backspace':
|
||||
// 필터 텍스트 삭제, '/'까지 지우면 팝업 닫기
|
||||
if (this.filterText.length > 0) {
|
||||
this.filterText = this.filterText.slice(0, -1);
|
||||
this.updateFilter();
|
||||
} else {
|
||||
// '/' 자체를 지우는 경우 → 팝업 닫기
|
||||
this.hide();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// 일반 문자 입력 시 필터링 적용
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
this.filterText += e.key;
|
||||
this.updateFilter();
|
||||
// 필터 결과가 없으면 팝업 닫기
|
||||
if (this.filteredCommands.length === 0) {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, true); // capture 단계
|
||||
|
||||
// 에디터 keyup 이벤트 ('/' 입력 감지)
|
||||
editorEl.addEventListener('keyup', (e) => {
|
||||
console.log('[SlashCmd] keyup:', e.key, 'isOpen:', this.isOpen);
|
||||
if (this.isOpen) return; // 이미 열려있으면 무시
|
||||
|
||||
if (e.key === '/') {
|
||||
console.log('[SlashCmd] / 감지, WYSIWYG:', this.editorRef.isWysiwygMode());
|
||||
// WYSIWYG 모드에서만 동작
|
||||
if (!this.editorRef.isWysiwygMode()) return;
|
||||
|
||||
// 줄 시작이거나 공백 뒤에서만 팝업 활성화
|
||||
const shouldActivate = this._shouldActivate();
|
||||
console.log('[SlashCmd] shouldActivate:', shouldActivate);
|
||||
if (shouldActivate) {
|
||||
const rect = this._getCursorRect();
|
||||
console.log('[SlashCmd] cursorRect:', rect);
|
||||
if (rect) {
|
||||
this.filterText = '';
|
||||
this.filteredCommands = [...this.commands];
|
||||
this.show(rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
// 에디터 외부 클릭 시 팝업 닫기
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
if (this.isOpen && !this.popupEl.contains(e.target)) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// 에디터 스크롤/리사이즈 시 팝업 닫기
|
||||
editorEl.addEventListener('scroll', () => { if (this.isOpen) this.hide(); }, true);
|
||||
window.addEventListener('resize', () => { if (this.isOpen) this.hide(); });
|
||||
},
|
||||
|
||||
/**
|
||||
* '/' 입력이 유효한 위치인지 판별
|
||||
* (줄 시작 또는 공백/빈 줄 뒤)
|
||||
*/
|
||||
_shouldActivate() {
|
||||
const sel = window.getSelection();
|
||||
console.log('[SlashCmd] _shouldActivate - sel:', !!sel, 'rangeCount:', sel?.rangeCount);
|
||||
if (!sel || sel.rangeCount === 0) return false;
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const node = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
console.log('[SlashCmd] node type:', node.nodeType, 'offset:', offset, 'nodeName:', node.nodeName);
|
||||
|
||||
// Case 1: 텍스트 노드 내부에 커서가 있는 경우
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const textBefore = node.textContent.substring(0, offset);
|
||||
console.log('[SlashCmd] TEXT_NODE textBefore:', JSON.stringify(textBefore));
|
||||
if (textBefore === '/' || textBefore.endsWith(' /') || textBefore.endsWith('\n/')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: 요소 노드 내부에 커서가 있는 경우 (WYSIWYG contenteditable)
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
// offset 위치의 바로 앞 자식 노드 확인
|
||||
const childBefore = node.childNodes[offset - 1];
|
||||
console.log('[SlashCmd] ELEMENT_NODE childBefore:', childBefore?.nodeType, 'text:', JSON.stringify(childBefore?.textContent));
|
||||
|
||||
if (childBefore) {
|
||||
const text = childBefore.textContent || '';
|
||||
if (text === '/' || text.endsWith(' /') || text.endsWith('\n/')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 요소의 전체 텍스트에서 마지막 문자 확인 (fallback)
|
||||
const fullText = node.textContent || '';
|
||||
console.log('[SlashCmd] ELEMENT_NODE fullText:', JSON.stringify(fullText));
|
||||
if (fullText === '/' || fullText.endsWith(' /') || fullText.endsWith('\n/')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[SlashCmd] shouldActivate → false (조건 불충족)');
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* 현재 커서의 화면 좌표(px) 반환
|
||||
*/
|
||||
_getCursorRect() {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return null;
|
||||
|
||||
const range = sel.getRangeAt(0).cloneRange();
|
||||
range.collapse(true);
|
||||
|
||||
// 빈 영역에서도 좌표를 얻기 위해 임시 span 삽입
|
||||
const span = document.createElement('span');
|
||||
span.textContent = '\u200b'; // zero-width space
|
||||
range.insertNode(span);
|
||||
const rect = span.getBoundingClientRect();
|
||||
const result = { top: rect.top, left: rect.left, bottom: rect.bottom };
|
||||
span.parentNode.removeChild(span);
|
||||
|
||||
// Selection 복원
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* 팝업 표시
|
||||
*/
|
||||
show(rect) {
|
||||
this.selectedIndex = 0;
|
||||
this.isOpen = true;
|
||||
this._renderItems();
|
||||
|
||||
// 팝업 위치 계산 (커서 바로 아래)
|
||||
const popupHeight = this.popupEl.offsetHeight || 280;
|
||||
const viewportH = window.innerHeight;
|
||||
|
||||
// 화면 아래 공간이 부족하면 위에 표시
|
||||
if (rect.bottom + popupHeight > viewportH) {
|
||||
this.popupEl.style.top = `${rect.top - popupHeight - 4}px`;
|
||||
} else {
|
||||
this.popupEl.style.top = `${rect.bottom + 4}px`;
|
||||
}
|
||||
this.popupEl.style.left = `${Math.max(8, rect.left)}px`;
|
||||
this.popupEl.style.display = 'block';
|
||||
},
|
||||
|
||||
/**
|
||||
* 팝업 숨기기
|
||||
*/
|
||||
hide() {
|
||||
this.isOpen = false;
|
||||
this.popupEl.style.display = 'none';
|
||||
this.filterText = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* 필터링 업데이트
|
||||
*/
|
||||
updateFilter() {
|
||||
const q = this.filterText.toLowerCase();
|
||||
const isAIDisabled = document.body.classList.contains('ai-disabled');
|
||||
|
||||
this.filteredCommands = this.commands.filter(c => {
|
||||
if (c.isAI && isAIDisabled) return false;
|
||||
return c.label.toLowerCase().includes(q) || c.cmd.toLowerCase().includes(q);
|
||||
});
|
||||
this.selectedIndex = 0;
|
||||
this._renderItems();
|
||||
},
|
||||
|
||||
/**
|
||||
* 팝업 내 항목 DOM 렌더링
|
||||
*/
|
||||
_renderItems() {
|
||||
this.popupEl.innerHTML = this.filteredCommands.map((c, i) => `
|
||||
<div class="slash-item ${i === this.selectedIndex ? 'selected' : ''}" data-index="${i}">
|
||||
<span class="slash-icon">${c.icon}</span>
|
||||
<span class="slash-label">${c.label}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// 마우스 클릭 이벤트
|
||||
this.popupEl.querySelectorAll('.slash-item').forEach(item => {
|
||||
item.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault(); // 에디터 포커스 유지
|
||||
this.selectedIndex = parseInt(item.dataset.index);
|
||||
this.executeSelected();
|
||||
});
|
||||
item.addEventListener('mouseenter', () => {
|
||||
this.selectedIndex = parseInt(item.dataset.index);
|
||||
this._highlightSelected();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 선택 항목 하이라이트 갱신
|
||||
*/
|
||||
_highlightSelected() {
|
||||
this.popupEl.querySelectorAll('.slash-item').forEach((el, i) => {
|
||||
el.classList.toggle('selected', i === this.selectedIndex);
|
||||
});
|
||||
|
||||
// 선택된 항목이 보이도록 스크롤
|
||||
const selectedEl = this.popupEl.querySelector('.slash-item.selected');
|
||||
if (selectedEl) {
|
||||
selectedEl.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* ↑↓ 네비게이션
|
||||
*/
|
||||
navigate(direction) {
|
||||
const len = this.filteredCommands.length;
|
||||
if (len === 0) return;
|
||||
this.selectedIndex = (this.selectedIndex + direction + len) % len;
|
||||
this._highlightSelected();
|
||||
},
|
||||
|
||||
/**
|
||||
* 선택된 명령 실행
|
||||
*/
|
||||
executeSelected() {
|
||||
const cmd = this.filteredCommands[this.selectedIndex];
|
||||
if (!cmd) { this.hide(); return; }
|
||||
|
||||
// 1. '/' + 필터 텍스트를 에디터에서 삭제
|
||||
this._deleteSlashAndFilter();
|
||||
|
||||
// 2. 팝업 닫기
|
||||
this.hide();
|
||||
|
||||
// 3. 에디터 포커스 유지 후 명령 실행
|
||||
this.editorRef.focus();
|
||||
|
||||
// 짧은 딜레이 후 명령 실행 (DOM 반영 대기)
|
||||
requestAnimationFrame(() => {
|
||||
if (cmd.payload) {
|
||||
this.editorRef.exec(cmd.cmd, cmd.payload);
|
||||
} else {
|
||||
this.editorRef.exec(cmd.cmd);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* '/' 문자와 필터 텍스트를 에디터 본문에서 삭제
|
||||
*/
|
||||
_deleteSlashAndFilter() {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return;
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const node = range.startContainer;
|
||||
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const offset = range.startOffset;
|
||||
const deleteLen = 1 + this.filterText.length; // '/' + filter
|
||||
const start = offset - deleteLen;
|
||||
|
||||
if (start >= 0) {
|
||||
// 텍스트 노드에서 직접 삭제
|
||||
node.textContent = node.textContent.substring(0, start) + node.textContent.substring(offset);
|
||||
|
||||
// 커서를 삭제 위치로 복원
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(node, start);
|
||||
newRange.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(newRange);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import { API } from '../api.js';
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
|
||||
export const ThemeManager = {
|
||||
/**
|
||||
* 환경 설정 및 개인화 테마 로직 초기화
|
||||
*/
|
||||
async initSettings() {
|
||||
const settingsBtn = document.getElementById('settingsBtn');
|
||||
const settingsModal = document.getElementById('settingsModal');
|
||||
const closeSettingsBtn = document.getElementById('closeSettingsBtn');
|
||||
const saveThemeBtn = document.getElementById('saveThemeBtn');
|
||||
const resetThemeBtn = document.getElementById('resetThemeBtn');
|
||||
const pickers = settingsModal.querySelectorAll('input[type="color"]');
|
||||
|
||||
// 1. 서버에서 설정 불러오기 및 적용
|
||||
try {
|
||||
const settings = await API.fetchSettings();
|
||||
await this.applyTheme(settings);
|
||||
// 만약 서버에 설정된 테마가 없다면 시스템 테마 감지 시작
|
||||
if (Object.keys(settings).length === 0) {
|
||||
this.initSystemThemeDetection();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err);
|
||||
this.initSystemThemeDetection();
|
||||
}
|
||||
|
||||
// ... 나머지 모달 제어 로직 유지 (기존 코드와 동일)
|
||||
if (settingsBtn) settingsBtn.onclick = () => settingsModal.classList.add('active');
|
||||
if (closeSettingsBtn) closeSettingsBtn.onclick = () => settingsModal.classList.remove('active');
|
||||
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target === settingsModal) settingsModal.classList.remove('active');
|
||||
});
|
||||
|
||||
pickers.forEach(picker => {
|
||||
picker.oninput = (e) => {
|
||||
const variable = e.target.dataset.var;
|
||||
const value = e.target.value;
|
||||
document.documentElement.style.setProperty(variable, value);
|
||||
};
|
||||
});
|
||||
|
||||
if (saveThemeBtn) {
|
||||
saveThemeBtn.onclick = async () => {
|
||||
const data = {};
|
||||
const mapping = {
|
||||
'set-bg': 'bg_color',
|
||||
'set-sidebar': 'sidebar_color',
|
||||
'set-card': 'card_color',
|
||||
'set-encrypted': 'encrypted_border',
|
||||
'set-ai': 'ai_accent'
|
||||
};
|
||||
|
||||
pickers.forEach(p => {
|
||||
data[mapping[p.id]] = p.value;
|
||||
});
|
||||
data['enable_ai'] = document.getElementById('set-enable-ai').checked;
|
||||
|
||||
// 언어 설정이 UI에 있다면 추가 (현재는 config.json 수동 명시 권장이나 대비책 마련)
|
||||
const langSelect = document.getElementById('set-lang');
|
||||
if (langSelect) data['lang'] = langSelect.value;
|
||||
|
||||
try {
|
||||
await API.saveSettings(data);
|
||||
await this.applyTheme(data);
|
||||
alert(I18nManager.t('msg_settings_saved'));
|
||||
settingsModal.classList.remove('active');
|
||||
} catch (err) { alert('저장 실패: ' + err.message); }
|
||||
};
|
||||
}
|
||||
|
||||
if (resetThemeBtn) {
|
||||
resetThemeBtn.onclick = () => {
|
||||
if (confirm('모든 색상을 기본값으로 되돌릴까요?')) {
|
||||
const defaults = {
|
||||
bg_color: "#0f172a",
|
||||
sidebar_color: "rgba(30, 41, 59, 0.7)",
|
||||
card_color: "rgba(30, 41, 59, 0.85)",
|
||||
encrypted_border: "#00f3ff",
|
||||
ai_accent: "#8b5cf6",
|
||||
lang: "ko"
|
||||
};
|
||||
this.applyTheme(defaults);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 테마 데이터를 실제 CSS 변수 및 UI 요소에 반영
|
||||
*/
|
||||
async applyTheme(settings) {
|
||||
const mapping = {
|
||||
'bg_color': '--bg',
|
||||
'sidebar_color': '--sidebar',
|
||||
'card_color': '--card',
|
||||
'encrypted_border': '--encrypted-border',
|
||||
'ai_accent': '--ai-accent'
|
||||
};
|
||||
|
||||
for (const [key, variable] of Object.entries(mapping)) {
|
||||
if (settings[key]) {
|
||||
document.documentElement.style.setProperty(variable, settings[key]);
|
||||
const pickerId = 'set-' + key.split('_')[0];
|
||||
const picker = document.getElementById(pickerId);
|
||||
if (picker) {
|
||||
picker.value = settings[key].startsWith('rgba') ? this.rgbaToHex(settings[key]) : settings[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. AI 활성화 상태 적용
|
||||
const enableAI = (settings.enable_ai !== false);
|
||||
document.body.classList.toggle('ai-disabled', !enableAI);
|
||||
const aiToggle = document.getElementById('set-enable-ai');
|
||||
if (aiToggle) aiToggle.checked = enableAI;
|
||||
|
||||
// 3. i18n 적용
|
||||
const lang = settings.lang || 'ko';
|
||||
await I18nManager.init(lang);
|
||||
const langSelect = document.getElementById('set-lang');
|
||||
if (langSelect) langSelect.value = lang;
|
||||
},
|
||||
|
||||
rgbaToHex(rgba) {
|
||||
const parts = rgba.match(/[\d.]+/g);
|
||||
if (!parts || parts.length < 3) return '#0f172a';
|
||||
const r = parseInt(parts[0]);
|
||||
const g = parseInt(parts[1]);
|
||||
const b = parseInt(parts[2]);
|
||||
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
|
||||
},
|
||||
|
||||
/**
|
||||
* 시스템 다크/라이트 모드 감지 및 자동 적용
|
||||
*/
|
||||
initSystemThemeDetection() {
|
||||
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleThemeChange = (e) => {
|
||||
const isDark = e.matches;
|
||||
const theme = isDark ? {
|
||||
bg_color: "#0f172a",
|
||||
sidebar_color: "rgba(30, 41, 59, 0.7)",
|
||||
card_color: "rgba(30, 41, 59, 0.85)",
|
||||
encrypted_border: "#00f3ff",
|
||||
ai_accent: "#8b5cf6",
|
||||
lang: "ko"
|
||||
} : {
|
||||
bg_color: "#f8fafc",
|
||||
sidebar_color: "rgba(241, 245, 249, 0.8)",
|
||||
card_color: "#ffffff",
|
||||
encrypted_border: "#0ea5e9",
|
||||
ai_accent: "#6366f1",
|
||||
lang: "ko"
|
||||
};
|
||||
this.applyTheme(theme);
|
||||
};
|
||||
|
||||
darkModeMediaQuery.addEventListener('change', handleThemeChange);
|
||||
handleThemeChange(darkModeMediaQuery);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* 지식 시각화 맵(Graph) 관리 모듈 (v7.5 - D3.js 기반 혁신)
|
||||
*/
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
import { Constants } from '../utils/Constants.js';
|
||||
|
||||
export const Visualizer = {
|
||||
simulation: null,
|
||||
svg: null,
|
||||
container: null,
|
||||
width: 0,
|
||||
height: 0,
|
||||
|
||||
init(containerId) {
|
||||
this.container = document.getElementById(containerId);
|
||||
if (!this.container) {
|
||||
console.error(`[Visualizer] Container #${containerId} not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 초기 크기 설정
|
||||
this.width = this.container.clientWidth;
|
||||
this.height = this.container.clientHeight;
|
||||
console.log(`[Visualizer] Init - Size: ${this.width}x${this.height}`);
|
||||
},
|
||||
|
||||
render(memos, onNodeClick) {
|
||||
console.log(`[Visualizer] Rendering ${memos.length} memos...`);
|
||||
if (!this.container) return;
|
||||
|
||||
// 모달이 열리는 중이라 크기가 0일 경우 대비 재측정
|
||||
if (this.width === 0 || this.height === 0) {
|
||||
this.width = this.container.clientWidth || 800;
|
||||
this.height = this.container.clientHeight || 600;
|
||||
console.log(`[Visualizer] Re-measured Size: ${this.width}x${this.height}`);
|
||||
}
|
||||
|
||||
// 0. 기존 내용 청소
|
||||
this.container.innerHTML = '';
|
||||
|
||||
// 1. 데이터 전처리
|
||||
const uniqueGroups = [...new Set(memos.map(m => m.group_name || Constants.GROUPS.DEFAULT))];
|
||||
const groupCenters = {};
|
||||
const radius = Math.min(this.width, this.height) * 0.35;
|
||||
|
||||
// 그룹별 성단 중심점 계산 (원형 레이아웃)
|
||||
uniqueGroups.forEach((g, i) => {
|
||||
const angle = (i / uniqueGroups.length) * Math.PI * 2;
|
||||
groupCenters[g] = {
|
||||
x: this.width / 2 + Math.cos(angle) * radius,
|
||||
y: this.height / 2 + Math.sin(angle) * radius
|
||||
};
|
||||
});
|
||||
|
||||
const nodes = memos.map(m => ({
|
||||
...m,
|
||||
id: m.id.toString(),
|
||||
group: m.group_name || Constants.GROUPS.DEFAULT,
|
||||
weight: (m.links ? m.links.length : 0) + 5
|
||||
}));
|
||||
|
||||
const links = [];
|
||||
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
||||
|
||||
// 1. 명시적 링크 (Internal Links) 처리
|
||||
memos.forEach(m => {
|
||||
if (m.links) {
|
||||
m.links.forEach(l => {
|
||||
const targetId = (l.target_id || l.id).toString();
|
||||
if (nodeMap.has(targetId)) {
|
||||
links.push({ source: m.id.toString(), target: targetId, type: 'explicit' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 공통 태그 및 그룹 기반 자동 연결 (Constellation Links)
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const nodeA = nodes[i];
|
||||
const nodeB = nodes[j];
|
||||
|
||||
// 태그 목록 추출
|
||||
const tagsA = new Set((nodeA.tags || []).map(t => t.name));
|
||||
const tagsB = new Set((nodeB.tags || []).map(t => t.name));
|
||||
|
||||
// 교집합 확인 (태그 링크)
|
||||
const commonTags = [...tagsA].filter(t => tagsB.has(t));
|
||||
if (commonTags.length > 0) {
|
||||
links.push({
|
||||
source: nodeA.id,
|
||||
target: nodeB.id,
|
||||
type: 'tag',
|
||||
strength: commonTags.length
|
||||
});
|
||||
} else if (nodeA.group === nodeB.group) {
|
||||
// 동일 그룹 내 자동 연결 (성단 형성) - 태그가 없을 때만
|
||||
links.push({
|
||||
source: nodeA.id,
|
||||
target: nodeB.id,
|
||||
type: 'group',
|
||||
strength: 0.1
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Visualizer] Data Prepared - Nodes: ${nodes.length}, Links: ${links.length}, Groups: ${uniqueGroups.length}`);
|
||||
const totalTags = nodes.reduce((acc, n) => acc + (n.tags ? n.tags.length : 0), 0);
|
||||
console.log(`[Visualizer] Total Tags in Data: ${totalTags}`);
|
||||
|
||||
// 2. SVG 생성
|
||||
this.svg = d3.select(this.container)
|
||||
.append('svg')
|
||||
.attr('width', '100%')
|
||||
.attr('height', '100%')
|
||||
.style('background', 'radial-gradient(circle at center, #1e293b 0%, #020617 100%)')
|
||||
.attr('viewBox', `0 0 ${this.width} ${this.height}`);
|
||||
|
||||
// 우주 배경 (작은 별들) 생성
|
||||
const starCount = 100;
|
||||
const stars = Array.from({ length: starCount }, () => ({
|
||||
x: Math.random() * this.width,
|
||||
y: Math.random() * this.height,
|
||||
r: Math.random() * 1.5,
|
||||
opacity: Math.random()
|
||||
}));
|
||||
|
||||
this.svg.selectAll('.star')
|
||||
.data(stars)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('class', 'star')
|
||||
.attr('cx', d => d.x)
|
||||
.attr('cy', d => d.y)
|
||||
.attr('r', d => d.r)
|
||||
.style('fill', '#fff')
|
||||
.style('opacity', d => d.opacity);
|
||||
|
||||
// 글로우 효과 필터 정의
|
||||
const defs = this.svg.append('defs');
|
||||
const filter = defs.append('filter')
|
||||
.attr('id', 'glow');
|
||||
filter.append('feGaussianBlur')
|
||||
.attr('stdDeviation', '3.5')
|
||||
.attr('result', 'coloredBlur');
|
||||
const feMerge = filter.append('feMerge');
|
||||
feMerge.append('feMergeNode').attr('in', 'coloredBlur');
|
||||
feMerge.append('feMergeNode').attr('in', 'SourceGraphic');
|
||||
|
||||
const g = this.svg.append('g').attr('class', 'main-g');
|
||||
|
||||
// 3. 줌(Zoom) 설정
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.1, 5])
|
||||
.on('zoom', (event) => g.attr('transform', event.transform));
|
||||
this.svg.call(zoom);
|
||||
|
||||
// 4. 그룹 라벨 생성 (Subtle Center Labels)
|
||||
const groupLabels = g.selectAll('.group-label')
|
||||
.data(uniqueGroups)
|
||||
.join('text')
|
||||
.attr('class', 'group-label')
|
||||
.attr('x', d => groupCenters[d].x)
|
||||
.attr('y', d => groupCenters[d].y)
|
||||
.text(d => d)
|
||||
.style('fill', 'rgba(56, 189, 248, 0.2)')
|
||||
.style('font-size', '14px')
|
||||
.style('font-weight', 'bold')
|
||||
.style('text-anchor', 'middle')
|
||||
.style('pointer-events', 'none');
|
||||
|
||||
// 5. 물리 시뮬레이션 설정 (Force Simulation)
|
||||
this.simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links).id(d => d.id).distance(100).strength(0.1))
|
||||
.force('charge', d3.forceManyBody().strength(-200)) // 서로 밀어냄
|
||||
.force('collide', d3.forceCollide().radius(d => d.weight + 20))
|
||||
.force('x', d3.forceX(d => groupCenters[d.group].x).strength(0.08)) // 그룹 중심으로 당김
|
||||
.force('y', d3.forceY(d => groupCenters[d.group].y).strength(0.08))
|
||||
.force('center', d3.forceCenter(this.width / 2, this.height / 2).strength(0.01));
|
||||
|
||||
// 6. 링크(선) 활성화
|
||||
const link = g.selectAll('.link')
|
||||
.data(links)
|
||||
.join('line')
|
||||
.attr('class', 'link')
|
||||
.style('stroke', d => {
|
||||
if (d.type === 'explicit') return '#38bdf8';
|
||||
if (d.type === 'tag') return '#8b5cf6';
|
||||
return 'rgba(56, 189, 248, 0.05)'; // group links
|
||||
})
|
||||
.style('stroke-width', d => d.type === 'explicit' ? 2 : 1)
|
||||
.style('stroke-dasharray', d => d.type === 'group' ? '2,2' : 'none')
|
||||
.style('opacity', d => d.type === 'group' ? 0.3 : 0.6);
|
||||
|
||||
// 7. 노드(점) 활성화
|
||||
const node = g.selectAll('.node')
|
||||
.data(nodes)
|
||||
.join('g')
|
||||
.attr('class', d => `node ${d.is_encrypted ? 'encrypted' : ''}`)
|
||||
.call(d3.drag()
|
||||
.on('start', dragstarted)
|
||||
.on('drag', dragged)
|
||||
.on('end', dragended))
|
||||
.on('click', (event, d) => onNodeClick && onNodeClick(d.id))
|
||||
.on('mouseover', function(event, d) {
|
||||
// 이웃 노드 및 링크 하이라이트
|
||||
const neighborIds = new Set();
|
||||
neighborIds.add(d.id);
|
||||
links.forEach(l => {
|
||||
if (l.source.id === d.id) neighborIds.add(l.target.id);
|
||||
if (l.target.id === d.id) neighborIds.add(l.source.id);
|
||||
});
|
||||
|
||||
node.style('opacity', n => neighborIds.has(n.id) ? 1 : 0.1);
|
||||
link.style('stroke', l => (l.source.id === d.id || l.target.id === d.id) ? '#38bdf8' : 'rgba(56, 189, 248, 0.05)')
|
||||
.style('stroke-opacity', l => (l.source.id === d.id || l.target.id === d.id) ? 1 : 0.2);
|
||||
})
|
||||
.on('mouseout', function() {
|
||||
node.style('opacity', 1);
|
||||
link.style('stroke', 'rgba(56, 189, 248, 0.1)')
|
||||
.style('stroke-opacity', 0.6);
|
||||
});
|
||||
|
||||
// 노드 원형 스타일
|
||||
node.append('circle')
|
||||
.attr('r', d => d.weight)
|
||||
.style('fill', d => d.is_encrypted ? '#64748b' : '#38bdf8')
|
||||
.style('filter', 'url(#glow)')
|
||||
.style('cursor', 'pointer');
|
||||
|
||||
// 노드 텍스트 라벨
|
||||
node.append('text')
|
||||
.attr('dy', d => d.weight + 15)
|
||||
.text(d => {
|
||||
const untitled = I18nManager.t('label_untitled');
|
||||
const title = d.title || untitled;
|
||||
return d.is_encrypted ? `🔒 ${title}` : title;
|
||||
})
|
||||
.style('fill', d => d.is_encrypted ? '#94a3b8' : '#cbd5e1')
|
||||
.style('font-size', '10px')
|
||||
.style('text-anchor', 'middle')
|
||||
.style('pointer-events', 'none')
|
||||
.style('text-shadow', '0 2px 4px rgba(0,0,0,0.8)');
|
||||
|
||||
// 8. 틱(Tick)마다 좌표 업데이트
|
||||
this.simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', d => d.source.x)
|
||||
.attr('y1', d => d.source.y)
|
||||
.attr('x2', d => d.target.x)
|
||||
.attr('y2', d => d.target.y);
|
||||
|
||||
node
|
||||
.attr('transform', d => `translate(${d.x}, ${d.y})`);
|
||||
});
|
||||
|
||||
// 드래그 함수
|
||||
const self = this;
|
||||
function dragstarted(event, d) {
|
||||
if (!event.active) self.simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
function dragged(event, d) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event, d) {
|
||||
if (!event.active) self.simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}
|
||||
},
|
||||
|
||||
resize() {
|
||||
if (!this.container || !this.svg) return;
|
||||
this.width = this.container.clientWidth;
|
||||
this.height = this.container.clientHeight;
|
||||
this.svg.attr('viewBox', `0 0 ${this.width} ${this.height}`);
|
||||
this.simulation.force('center', d3.forceCenter(this.width / 2, this.height / 2));
|
||||
this.simulation.alpha(0.3).restart();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user