Initial Global Release v1.0 (Localization & Security Hardening)

This commit is contained in:
leeyj
2026-04-16 01:12:43 +09:00
commit 175a30325b
67 changed files with 6348 additions and 0 deletions
+39
View File
@@ -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;
}
+159
View File
@@ -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">&lt;</button>
<span>${monthYearHeader}</span>
<button id="nextMonth">&gt;</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);
};
});
}
};
+225
View File
@@ -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');
}
};
+152
View File
@@ -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');
}
}
};
+148
View File
@@ -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();
};
}
}
};
+93
View File
@@ -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
};
}
+212
View File
@@ -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);
});
});
}
};
+47
View File
@@ -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);
});
}
+352
View File
@@ -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);
}
}
}
};
+165
View File
@@ -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);
},
};
+286
View File
@@ -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();
}
};