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
+194
View File
@@ -0,0 +1,194 @@
/**
* 뇌사료 메인 엔트리 포인트 (v5.0 리팩토링 완료)
*/
import { API } from './js/api.js';
import { UI } from './js/ui.js';
import { AppService } from './js/AppService.js';
import { EditorManager } from './js/editor.js';
import { ComposerManager } from './js/components/ComposerManager.js';
import { CalendarManager } from './js/components/CalendarManager.js';
import { Visualizer } from './js/components/Visualizer.js';
import { HeatmapManager } from './js/components/HeatmapManager.js';
import { DrawerManager } from './js/components/DrawerManager.js';
import { ModalManager } from './js/components/ModalManager.js';
import { I18nManager } from './js/utils/I18nManager.js';
import { Constants } from './js/utils/Constants.js';
document.addEventListener('DOMContentLoaded', async () => {
// --- 🔹 Initialization ---
await UI.initSettings(); // ⭐ i18n 및 테마 로딩 완료까지 최우선 대기
EditorManager.init('#editor');
// 작성기 초기화 (저장 성공 시 데이터 새로고침 콜백 등록)
ComposerManager.init(() => AppService.refreshData(updateSidebarCallback));
HeatmapManager.init('heatmapContainer'); // 히트맵 초기화
DrawerManager.init();
Visualizer.init('graphContainer');
UI.initSidebarToggle();
// --- 🔹 Callbacks ---
const updateSidebarCallback = (memos, activeGroup) => {
UI.updateSidebar(memos, activeGroup, (newFilter) => {
if (newFilter === Constants.GROUPS.FILES) {
ModalManager.openAssetLibrary((id, ms) => UI.openMemoModal(id, ms));
} else {
AppService.setFilter({ group: newFilter }, updateSidebarCallback);
}
});
};
// 달력 초기화
CalendarManager.init('calendarContainer', (date) => {
AppService.setFilter({ date }, updateSidebarCallback);
});
// 무한 스크롤 초기화
UI.initInfiniteScroll(() => {
AppService.loadMore(updateSidebarCallback);
});
// 드래그 앤 드롭 파일 탐지
EditorManager.bindDropEvent('.composer-wrapper', (shouldOpen) => {
if (shouldOpen && ComposerManager.DOM.composer.style.display === 'none') {
ComposerManager.openEmpty();
}
});
// --- 🔹 Global Event Handlers for Memo Cards ---
window.memoEventHandlers = {
onEdit: (id) => {
const memo = AppService.state.memosCache.find(m => m.id == id);
ComposerManager.openForEdit(memo);
},
onDelete: async (id) => {
if (confirm(I18nManager.t('msg_delete_confirm'))) {
await API.deleteMemo(id);
AppService.refreshData(updateSidebarCallback);
}
},
onAI: async (id) => {
UI.showLoading(true);
try {
await API.triggerAI(id);
await AppService.refreshData(updateSidebarCallback);
} catch (err) { alert(err.message); }
finally { UI.showLoading(false); }
},
onTogglePin: async (id) => {
const memo = AppService.state.memosCache.find(m => m.id == id);
await API.saveMemo({ is_pinned: !memo.is_pinned }, id);
AppService.refreshData(updateSidebarCallback);
},
onToggleStatus: async (id) => {
const memo = AppService.state.memosCache.find(m => m.id == id);
const newStatus = memo.status === 'done' ? 'active' : 'done';
await API.saveMemo({ status: newStatus }, id);
AppService.refreshData(updateSidebarCallback);
},
onOpenModal: (id) => UI.openMemoModal(id, AppService.state.memosCache),
onUnlock: async (id) => {
const password = prompt(I18nManager.t('prompt_password'));
if (!password) return;
try {
const data = await API.decryptMemo(id, password);
const memo = AppService.state.memosCache.find(m => m.id == id);
if (memo) {
memo.content = data.content;
memo.is_encrypted = false;
memo.was_encrypted = true;
memo.tempPassword = password;
// 검색 필터 적용 (현재 데이터 기준)
UI.renderMemos(AppService.state.memosCache, {}, window.memoEventHandlers, false);
}
} catch (err) { alert(err.message); }
}
};
// --- 🔹 Search & Graph ---
const searchInput = document.getElementById('searchInput');
let searchTimer;
searchInput.oninput = () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
AppService.setFilter({ query: searchInput.value }, updateSidebarCallback);
}, 300);
};
document.getElementById('openGraphBtn').onclick = () => {
document.getElementById('graphModal').classList.add('active');
setTimeout(() => {
Visualizer.render(AppService.state.memosCache, (id) => {
document.getElementById('graphModal').classList.remove('active');
UI.openMemoModal(id, AppService.state.memosCache);
});
}, 150);
};
document.getElementById('closeGraphBtn').onclick = () => {
document.getElementById('graphModal').classList.remove('active');
};
document.getElementById('openExplorerBtn').onclick = () => {
DrawerManager.open(AppService.state.memosCache, AppService.state.currentFilterGroup, (filter) => {
AppService.setFilter({ group: filter }, updateSidebarCallback);
});
};
// --- 🔹 Global Shortcuts (Comprehensive Shift to Ctrl-based System) ---
document.addEventListener('keydown', (e) => {
const isCtrl = e.ctrlKey || e.metaKey;
const isAlt = e.altKey;
const key = e.key.toLowerCase();
// 1. ESC: 모든 창 닫기
if (e.key === 'Escape') {
document.querySelectorAll('.modal.active, .drawer.active').forEach(el => el.classList.remove('active'));
if (ComposerManager.DOM.composer.style.display === 'block') ComposerManager.close();
return;
}
// 2. Ctrl + Enter / Ctrl + S: 저장 (작성기 열려있을 때)
if (isCtrl && (key === 'enter' || key === 's')) {
if (ComposerManager.DOM.composer.style.display === 'block') {
e.preventDefault();
ComposerManager.handleSave(updateSidebarCallback);
}
return;
}
// 3. Ctrl + Shift + Key 조합들 (네비게이션)
if (isCtrl && e.shiftKey) {
e.preventDefault();
switch (key) {
case 'n': // 새 메모
ComposerManager.openEmpty();
break;
case 'g': // 지식 네뷸라
document.getElementById('openGraphBtn').click();
break;
case 'e': // 지식 탐색기
document.getElementById('openExplorerBtn').click();
break;
case 'c': // 캘린더 토글
CalendarManager.isCollapsed = !CalendarManager.isCollapsed;
localStorage.setItem('calendar_collapsed', CalendarManager.isCollapsed);
CalendarManager.updateCollapseUI();
break;
case 'q': // 닫기
document.querySelectorAll('.modal.active, .drawer.active').forEach(el => el.classList.remove('active'));
ComposerManager.close();
break;
}
}
// 4. Quake-style Shortcut: Alt + ` (새 메모)
if (isAlt && key === '`') {
e.preventDefault();
ComposerManager.openEmpty();
}
});
// --- 🔹 App Start ---
AppService.refreshData(updateSidebarCallback);
});
+98
View File
@@ -0,0 +1,98 @@
/* Composer & Editor */
.composer-wrapper { width: 100%; margin-bottom: 3rem; }
#composer input[type="text"] {
width: 100%;
background: transparent;
border: none;
outline: none;
color: white;
font-family: var(--font);
font-size: 1.1rem;
font-weight: 800;
height: 35px;
}
.meta-field {
background: rgba(0,0,0,0.3) !important;
border: 1px solid rgba(255,255,255,0.1) !important;
border-radius: 8px;
padding: 5px 10px !important;
font-size: 0.8rem !important;
color: var(--muted) !important;
}
.editor-resize-wrapper {
resize: vertical;
overflow: hidden;
min-height: 200px;
height: 350px;
border-radius: 8px;
margin-bottom: 10px;
}
.toastui-editor-defaultUI {
height: 100% !important;
border: 1px solid rgba(255,255,255,0.1) !important;
border-radius: 8px;
}
#editorAttachments {
width: 100%;
margin-bottom: 5px;
}
/* --- 키보드 단축키 힌트 바 --- */
.shortcut-hint-bar {
margin-top: 10px;
border-radius: 8px;
overflow: hidden;
}
.shortcut-toggle-btn {
background: rgba(56, 189, 248, 0.08);
border: 1px solid rgba(56, 189, 248, 0.15);
color: var(--muted);
font-size: 0.75rem;
padding: 5px 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
font-family: var(--font);
}
.shortcut-toggle-btn:hover {
background: rgba(56, 189, 248, 0.15);
color: var(--accent);
}
.shortcut-details {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 10px 4px;
animation: fadeSlideIn 0.2s ease;
}
.shortcut-details .sk {
font-size: 0.72rem;
color: var(--muted);
white-space: nowrap;
}
.shortcut-details kbd {
display: inline-block;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
padding: 1px 5px;
font-size: 0.68rem;
font-family: 'Inter', monospace;
color: var(--accent);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
margin: 0 1px;
}
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
+108
View File
@@ -0,0 +1,108 @@
/* 🌡️ Heatmap Component */
.heatmap-wrapper {
padding: 12px;
margin-top: 10px;
}
.heatmap-header {
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.heatmap-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.heatmap-select {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--muted);
font-size: 0.65rem;
padding: 2px 4px;
border-radius: 4px;
cursor: pointer;
outline: none;
transition: all 0.2s;
}
.heatmap-select:hover {
background: rgba(255, 255, 255, 0.1);
border-color: var(--accent);
color: var(--text);
}
.heatmap-select option {
background: var(--bg);
color: var(--text);
}
.heatmap-grid {
display: grid;
grid-template-rows: repeat(7, 10px);
grid-auto-flow: column;
grid-auto-columns: 10px;
gap: 3px;
overflow-x: auto;
padding-bottom: 5px;
}
/* Hide scrollbar for cleaner look */
.heatmap-grid::-webkit-scrollbar {
height: 3px;
}
.heatmap-grid::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.heatmap-cell {
width: 10px;
height: 10px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.05);
transition: transform 0.2s, filter 0.2s;
}
.heatmap-cell:hover {
transform: scale(1.3);
z-index: 10;
filter: brightness(1.2);
cursor: pointer;
}
.heatmap-cell.out {
opacity: 0;
pointer-events: none;
}
/* Level Colors (Cyan to Purple Gradient) */
.heatmap-cell.lvl-0 { background: rgba(255, 255, 255, 0.05); }
.heatmap-cell.lvl-1 { background: rgba(56, 189, 248, 0.3); }
.heatmap-cell.lvl-2 { background: rgba(56, 189, 248, 0.6); }
.heatmap-cell.lvl-3 { background: rgba(56, 189, 248, 0.9); }
.heatmap-cell.lvl-4 {
background: var(--ai-accent);
box-shadow: 0 0 5px var(--ai-accent);
}
.heatmap-legend {
display: flex;
align-items: center;
gap: 4px;
margin-top: 8px;
font-size: 0.65rem;
color: var(--muted);
justify-content: flex-end;
}
.heatmap-legend .heatmap-cell {
width: 8px;
height: 8px;
}
+104
View File
@@ -0,0 +1,104 @@
/* Masonry Grid Layout */
.masonry-grid { width: 100%; columns: auto 300px; column-gap: 1.5rem; }
/* Memo Card Styling */
.memo-card {
break-inside: avoid; margin-bottom: 1.5rem; background: rgba(30, 41, 59, 0.6);
border-radius: 12px; padding: 1.2rem 1.2rem 45px 1.2rem; border: 1px solid rgba(255,255,255,0.05);
position: relative; transition: transform 0.2s, background 0.2s;
max-height: 320px; overflow: hidden;
}
.memo-card:hover { transform: translateY(-4px); background: rgba(30, 41, 59, 1); }
.memo-card.compact { max-height: 80px; background: rgba(30, 41, 59, 0.3); border: 1px dashed rgba(255,255,255,0.1); }
.memo-card.compact .memo-content,
.memo-card.compact .memo-ai-summary,
.memo-card.compact .memo-backlinks { display: none; }
.memo-card.compact .memo-title { font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 0px; margin-right: 50px; }
.memo-card.compact::after { display: none; }
.memo-card.encrypted {
border: 1px solid var(--encrypted-border) !important;
box-shadow: 0 0 15px rgba(0, 243, 255, 0.15), inset 0 0 5px rgba(0, 243, 255, 0.05);
}
/* Fade-out for long content */
.memo-card::after {
content: '';
position: absolute;
bottom: 0; left: 0; width: 100%; height: 60px;
background: linear-gradient(transparent, rgba(15, 23, 42, 0.8));
pointer-events: none;
border-radius: 0 0 12px 12px;
}
/* Memo Content Elements */
.memo-title { font-weight: 600; font-size: 1.0rem; margin-bottom: 0.5rem; }
.memo-content { font-size: 0.9rem; color: #cbd5e1; line-height: 1.6; }
.memo-content img { max-width: 100%; border-radius: 8px; margin-top: 5px; }
.memo-summary, .memo-ai-summary {
font-size: 0.8rem;
font-style: italic;
color: var(--ai-accent);
border-left: 2px solid var(--ai-accent);
padding-left: 10px;
margin-bottom: 15px;
line-height: 1.5;
opacity: 0.9;
}
.memo-summary strong {
color: var(--ai-accent);
font-weight: 800;
text-transform: uppercase;
margin-right: 5px;
}
/* Meta & Badges */
.memo-meta { margin-bottom: 12px; display: flex; flex-wrap: wrap; gap: 5px; }
.tag-badge { padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: bold; }
.tag-user { background: rgba(56, 189, 248, 0.2); color: var(--accent); }
.tag-ai { background: rgba(139, 92, 246, 0.2); color: #c084fc; border: 1px solid rgba(139, 92, 246, 0.3); }
.group-badge { background: rgba(255,255,255,0.05); color: #cbd5e1; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: bold; }
/* Links & Actions */
.memo-backlinks { margin-top: 12px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.05); font-size: 0.8rem; color: var(--muted); }
.link-item { color: var(--accent); cursor: pointer; text-decoration: none; font-weight: 600; }
.link-item:hover { text-decoration: underline; }
.memo-actions { position: absolute; bottom: 10px; right: 10px; opacity: 0; transition: opacity 0.2s; display: flex; gap: 5px; }
.memo-card:hover .memo-actions { opacity: 1; }
/* Attachments */
.memo-attachments {
margin-top: 15px;
padding-top: 10px;
border-top: 1px solid rgba(255,255,255,0.05);
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.file-chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
padding: 6px 12px;
font-size: 0.8rem;
color: #cbd5e1;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.file-chip:hover {
background: rgba(255,255,255,0.1);
border-color: var(--accent);
color: white;
transform: translateY(-1px);
}
+94
View File
@@ -0,0 +1,94 @@
/* Modals & Special Overlay Components */
/* Asset Manager / Library */
.asset-card {
transition: transform 0.2s, background 0.2s;
border: 1px solid transparent;
}
.asset-card:hover {
transform: scale(1.02);
background: rgba(255,255,255,0.1) !important;
border-color: var(--accent);
}
/* Settings Modal Specifics */
.settings-grid {
display: grid;
grid-template-columns: 1fr auto;
gap: 15px;
align-items: center;
padding: 10px 0;
}
.settings-grid label { font-size: 0.95rem; font-weight: 600; color: #cbd5e1; }
.settings-grid input[type="color"] {
width: 50px; height: 32px; padding: 0;
border: 2px solid rgba(255,255,255,0.1); border-radius: 6px;
background: transparent; cursor: pointer; outline: none;
}
.settings-actions {
display: flex; justify-content: flex-end; gap: 10px;
margin-top: 25px; padding-top: 15px;
border-top: 1px solid rgba(255,255,255,0.05);
}
/* Universal Modal Close Button */
.close-modal-btn {
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
color: var(--muted);
font-size: 1.8rem;
line-height: 1;
cursor: pointer;
transition: all 0.2s;
z-index: 10;
padding: 5px;
}
.close-modal-btn:hover {
color: var(--accent);
transform: scale(1.1);
}
/* Explorer Chip Counts */
.chip-count {
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
color: var(--muted);
font-size: 0.7rem;
font-weight: 800;
padding: 2px 6px;
border-radius: 10px;
margin-left: 6px;
min-width: 18px;
transition: all 0.2s;
}
.explorer-chip:hover .chip-count {
background: var(--accent);
color: #0f172a;
}
/* AI Disabled State */
body.ai-disabled .ai-btn,
body.ai-disabled .ai-summary-box,
body.ai-disabled .memo-summary,
body.ai-disabled .tag-ai,
body.ai-disabled .ai-insight-icon,
body.ai-disabled .ai-badge,
body.ai-disabled .ai-loading-overlay {
display: none !important;
}
body.ai-disabled [data-source="ai"],
body.ai-disabled .drawer-section:has(h3:contains("태그별 탐색")) {
/* Note: :contains and :has are tricky, but we can target by class or JS filtering */
}
+81
View File
@@ -0,0 +1,81 @@
/* 슬래시 명령(/) 팝업 스타일 */
.slash-popup {
position: fixed;
z-index: 10000;
min-width: 180px;
max-width: 240px;
max-height: 280px;
overflow-y: auto;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(12px);
border: 1px solid rgba(56, 189, 248, 0.2);
border-radius: 10px;
padding: 6px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
animation: slashPopupIn 0.15s ease-out;
}
/* 스크롤바 커스텀 */
.slash-popup::-webkit-scrollbar { width: 4px; }
.slash-popup::-webkit-scrollbar-track { background: transparent; }
.slash-popup::-webkit-scrollbar-thumb {
background: rgba(56, 189, 248, 0.3);
border-radius: 2px;
}
.slash-item {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 10px;
border-radius: 7px;
cursor: pointer;
transition: background 0.12s ease, transform 0.1s ease;
user-select: none;
}
.slash-item:hover,
.slash-item.selected {
background: rgba(56, 189, 248, 0.12);
}
.slash-item.selected {
background: rgba(56, 189, 248, 0.18);
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.25) inset;
}
.slash-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
font-size: 0.8rem;
color: var(--accent, #38bdf8);
flex-shrink: 0;
font-weight: 700;
}
.slash-label {
font-size: 0.82rem;
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
font-family: var(--font, 'Inter', sans-serif);
}
@keyframes slashPopupIn {
from {
opacity: 0;
transform: translateY(-6px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
+184
View File
@@ -0,0 +1,184 @@
/* --- Visualization & Navigation (v3.0+) --- */
/* Sidebar Sections for Viz */
.sidebar-section {
margin-top: 10px;
padding: 0 5px;
}
.calendar-content {
max-height: 1000px;
overflow: hidden;
transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
opacity: 1;
}
.calendar-content.collapsed {
max-height: 0 !important;
opacity: 0;
pointer-events: none;
}
/* Calendar Widget */
.calendar-widget {
background: rgba(15, 23, 42, 0.4);
border-radius: 12px;
padding: 10px;
margin: 5px 0 15px 0;
border: 1px solid rgba(255, 255, 255, 0.05);
font-size: 0.8rem;
}
.calendar-nav {
display: flex;
justify-content: space-between; align-items: center;
margin-bottom: 10px; padding: 0 5px;
}
.calendar-nav span { font-weight: 600; color: var(--accent); }
.calendar-nav button {
background: none; border: none; color: var(--muted); cursor: pointer;
font-size: 1rem; padding: 2px 5px; border-radius: 4px;
}
.calendar-nav button:hover { color: white; background: rgba(255, 255, 255, 0.1); }
.calendar-grid {
display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; text-align: center;
}
.calendar-day-label { color: var(--muted); font-weight: 800; font-size: 0.7rem; margin-bottom: 5px; }
.calendar-day {
position: relative; padding: 6px 0; border-radius: 6px; cursor: pointer;
transition: background 0.2s; color: var(--text-dim);
}
.calendar-day:hover { background: rgba(255, 255, 255, 0.1); color: white; }
.calendar-day.today { color: var(--accent) !important; font-weight: 800; background: rgba(56, 189, 248, 0.15); }
.calendar-day.selected { background: var(--accent) !important; color: white !important; box-shadow: 0 0 10px var(--accent); }
.calendar-day.other-month { opacity: 0.2; }
/* Activity Dots */
.activity-dot {
position: absolute; bottom: 3px; left: 50%; transform: translateX(-50%);
width: 4px; height: 4px; background: var(--ai-accent); border-radius: 50%;
box-shadow: 0 0 5px var(--ai-accent);
}
/* Floating Knowledge Explorer (v3.9 Compact) */
.drawer {
position: fixed;
top: 100px;
left: calc(var(--sidebar-width) + 30px);
width: 320px; /* 크기 축소 */
max-height: 500px; /* 높이 제한 */
z-index: 1100;
transform: scale(0.9) translateY(20px);
opacity: 0;
visibility: hidden;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease, visibility 0.3s;
display: flex;
flex-direction: column;
padding: 0;
box-shadow: 0 20px 50px rgba(0,0,0,0.6);
border-radius: 16px; /* 더 날렵하게 */
background: rgba(15, 23, 42, 0.97) !important;
backdrop-filter: blur(40px);
border: 1px solid rgba(255, 255, 255, 0.15);
overflow: hidden;
}
.drawer.active {
transform: scale(1) translateY(0);
opacity: 1;
visibility: visible;
}
/* 드래그 중인 상태 */
.drawer.dragging {
transition: none; /* 드래그 중에는 애니메이션 끔 */
opacity: 0.8;
cursor: grabbing;
}
.drawer-header {
padding: 12px 18px; /* 여백 축소 */
border-bottom: 1px solid rgba(255,255,255,0.08);
display: flex;
justify-content: space-between;
align-items: center;
cursor: grab;
background: rgba(255,255,255,0.03);
}
.drawer-header h3 {
font-size: 0.9rem; /* 0.95 -> 0.9 */
font-weight: 800;
color: var(--accent);
margin: 0;
}
.drawer-body {
flex: 1;
overflow-y: auto;
padding: 15px 18px; /* 여백 축소 */
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.1) transparent;
}
.drawer .close-btn {
background: rgba(255,255,255,0.05);
border: none;
color: var(--muted);
font-size: 1rem;
width: 26px; /* 크기 축소 */
height: 26px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.drawer .close-btn:hover {
background: rgba(255,255,255,0.1);
color: white;
}
/* Colored Explorer Chips (Enhanced) */
.explorer-section {
margin-bottom: 25px; /* 여백 축소 */
}
.explorer-section h3 {
font-size: 0.65rem; /* 폰트 축소 */
text-transform: uppercase;
letter-spacing: 0.08rem;
color: var(--muted);
margin-bottom: 10px;
opacity: 0.6;
}
.explorer-grid {
display: flex;
flex-wrap: wrap;
gap: 8px; /* 간격 축소 */
}
.explorer-chip {
padding: 5px 12px; /* 여백 축소 */
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
cursor: pointer;
font-size: 0.75rem; /* 0.8 -> 0.75 */
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
color: var(--text-dim);
user-select: none;
}
.explorer-chip:hover { background: rgba(255, 255, 255, 0.1); transform: translateY(-1px); }
.explorer-chip.tag-user { border-color: rgba(56, 189, 248, 0.3); color: #bae6fd; }
.explorer-chip.tag-user:hover, .explorer-chip.tag-user.active { background: rgba(56, 189, 248, 0.15); border-color: var(--accent); color: white; }
.explorer-chip.tag-ai { border-color: rgba(168, 85, 247, 0.3); color: #e9d5ff; }
.explorer-chip.tag-ai:hover, .explorer-chip.tag-ai.active { background: rgba(168, 85, 247, 0.15); border-color: #a855f7; color: white; }
+107
View File
@@ -0,0 +1,107 @@
/* Main Content Area */
.content {
flex: 1;
overflow-y: auto;
padding: 2rem 3rem;
display: flex;
flex-direction: column;
align-items: center;
}
/* Top Navigation Bar */
.topbar {
width: 100%;
display: flex;
justify-content: space-between;
margin-bottom: 2.5rem;
}
.search-bar {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 12px;
padding: 0.8rem 1.2rem;
display: flex;
align-items: center;
gap: 0.5rem;
width: 400px;
}
.search-bar input {
background: transparent;
border: none;
outline: none;
color: white;
width: 100%;
font-family: var(--font);
}
/* Glass Panels & Modals Base */
.glass-panel {
background: var(--card); backdrop-filter: blur(16px);
border: 1px solid rgba(255,255,255,0.1); border-radius: 16px;
padding: 1.2rem; box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(5px);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal.active { display: flex; }
.modal-content {
width: 90%;
max-width: 800px;
max-height: 85vh;
overflow-y: auto;
background: var(--bg);
}
/* UI Utility Components */
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255,255,255,0.1);
border-top: 4px solid var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.primary-btn {
background: var(--accent);
color: #0f172a;
font-weight: 700;
border: none;
padding: 0.5rem 1.2rem;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.primary-btn:hover { background: var(--accent-hover); }
.action-btn {
background: rgba(0,0,0,0.4);
border: none;
border-radius: 4px;
padding: 5px 10px;
cursor: pointer;
color: white;
transition: background 0.2s;
font-weight: bold;
}
.action-btn:hover { background: rgba(184, 59, 94, 0.8); }
.ai-btn:hover { background: var(--ai-accent); color: white; }
+171
View File
@@ -0,0 +1,171 @@
/* 🧠 뇌사료 | Premium Login Styles (v4.1) */
:root {
--bg: #0b0f1a;
--card: rgba(22, 30, 46, 0.6);
--text: #f8fafc;
--muted: #94a3b8;
--accent: #38bdf8;
--accent-hover: #0ea5e9;
--ai-accent: #a855f7;
--font: 'Inter', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; font-family: var(--font); }
body {
background-color: var(--bg);
background-image:
radial-gradient(circle at 20% 30%, rgba(56, 189, 248, 0.1), transparent 40%),
radial-gradient(circle at 80% 70%, rgba(168, 85, 247, 0.1), transparent 40%);
color: var(--text);
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.login-container {
perspective: 1000px;
width: 100%;
max-width: 440px;
padding: 20px;
}
.login-card {
background: var(--card);
backdrop-filter: blur(25px);
-webkit-backdrop-filter: blur(25px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 32px;
padding: 60px 45px;
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.6);
text-align: center;
position: relative;
overflow: hidden;
animation: cardEntrance 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes cardEntrance {
from { opacity: 0; transform: translateY(40px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.login-card::before {
content: '';
position: absolute;
top: 0; left: 0; width: 100%; height: 6px;
background: linear-gradient(90deg, var(--accent), var(--ai-accent));
}
.logo-area {
margin-bottom: 40px;
}
.logo {
font-size: 3rem;
font-weight: 800;
margin-bottom: 12px;
background: linear-gradient(135deg, #38bdf8, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
display: inline-block;
}
.tagline {
font-size: 0.95rem;
color: var(--muted);
font-weight: 400;
}
/* Form Styling */
.input-group {
margin-bottom: 24px;
text-align: left;
position: relative;
}
.input-group label {
display: block;
margin-bottom: 8px;
font-size: 0.85rem;
color: var(--muted);
font-weight: 600;
margin-left: 4px;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.input-wrapper input {
width: 100%;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 16px 20px;
color: white;
outline: none;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 1rem;
}
.input-wrapper input:focus {
background: rgba(0, 0, 0, 0.6);
border-color: var(--accent);
box-shadow: 0 0 20px rgba(56, 189, 248, 0.1);
}
/* Button & Actions */
.login-btn {
width: 100%;
background: linear-gradient(135deg, var(--accent), #0ea5e9);
color: #0f172a;
border: none;
border-radius: 16px;
padding: 18px;
font-size: 1.1rem;
font-weight: 800;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 15px;
box-shadow: 0 10px 25px -5px rgba(56, 189, 248, 0.4);
}
.login-btn:hover {
transform: translateY(-3px);
background: linear-gradient(135deg, #7dd3fc, var(--accent-hover));
box-shadow: 0 15px 30px -5px rgba(56, 189, 248, 0.6);
}
.login-btn:active {
transform: translateY(-1px);
}
.error-msg {
color: #f87171;
font-size: 0.85rem;
margin-top: 20px;
background: rgba(248, 113, 113, 0.1);
padding: 10px;
border-radius: 8px;
display: none;
animation: shake 0.4s;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.login-card-footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
font-size: 0.8rem;
color: rgba(148, 163, 184, 0.5);
}
+49
View File
@@ -0,0 +1,49 @@
/* --- Mobile Optimization (v2.0+) --- */
@media (max-width: 768px) {
body { overflow-x: hidden; }
/* Sidebar as Drawer on Mobile */
.sidebar {
position: fixed; left: 0; top: 0; height: 100vh; z-index: 1000;
transform: translateX(-100%); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
width: 280px; box-shadow: 20px 0 50px rgba(0,0,0,0.5);
}
.sidebar.mobile-open { transform: translateX(0); }
.sidebar.collapsed { width: 280px; }
.sidebar.collapsed .text { display: inline !important; }
/* Mobile Content Layout */
.content { padding: 1rem; width: 100%; }
.topbar { margin-bottom: 1.5rem; flex-direction: row; align-items: center; justify-content: flex-start; gap: 0; }
.search-bar { flex: 1; order: 2; }
#mobileMenuBtn { display: block !important; order: 1; }
.sidebar-toggle { font-size: 1.5rem; padding: 10px; }
/* Composer Adjustments */
.meta-inputs { flex-direction: column; align-items: stretch !important; gap: 8px !important; }
.meta-field { width: 100% !important; height: 44px !important; font-size: 1rem !important; }
#composer input[type="text"] { height: 44px; font-size: 1.1rem; }
.editor-resize-wrapper { height: 300px; }
/* Card Layout for Mobile */
.masonry-grid { columns: 1; column-gap: 0; }
.memo-card { max-height: none; padding-bottom: 50px; }
.memo-card::after { height: 40px; }
.memo-actions { opacity: 1 !important; bottom: 8px; right: 8px; }
.action-btn { padding: 8px 12px; font-size: 1rem; min-width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: center; }
.modal-content { width: 95%; max-height: 90vh; border-radius: 12px; }
.tag-badge, .group-badge { padding: 4px 10px; font-size: 0.85rem; }
body { padding-bottom: env(safe-area-inset-bottom); }
/* Knowledge Drawer Mobile Adjustments */
.drawer {
left: 10px; right: 10px; width: auto;
transform: translateY(110%);
bottom: 10px; top: auto; height: 70vh;
}
.drawer.active { transform: translateY(0); }
}
+177
View File
@@ -0,0 +1,177 @@
.sidebar {
width: var(--sidebar-width);
height: 100vh;
background: var(--sidebar);
backdrop-filter: blur(10px);
border-right: 1px solid rgba(255,255,255,0.05);
padding: 2rem 1.2rem;
display: flex;
flex-direction: column;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), padding 0.3s ease;
flex-shrink: 0;
overflow: hidden; /* 전체 스크롤을 막고 내부 스크롤 활성화 */
}
.sidebar-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
margin: 0 -0.5rem; /* 스크롤바 공간 확보를 위해 패딩 살짝 조정 */
padding: 0 0.5rem;
}
/* Custom Scrollbar for Sidebar */
.sidebar-content::-webkit-scrollbar {
width: 4px;
}
.sidebar-content::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
.sidebar-content::-webkit-scrollbar-thumb:hover {
background: rgba(56, 189, 248, 0.3);
}
.sidebar-header { margin-bottom: 2.5rem; white-space: nowrap; overflow: hidden; }
.logo {
font-size: 1.3rem;
font-weight: 800;
background: linear-gradient(135deg, #38bdf8, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
transition: opacity 0.2s;
}
.sidebar-toggle {
background: none; border: none; color: var(--muted); font-size: 1.2rem; cursor: pointer;
padding: 5px; border-radius: 4px; transition: background 0.2s, color 0.2s;
}
.sidebar-toggle:hover { background: rgba(255,255,255,0.05); color: var(--text); }
/* Navigation List */
.nav { list-style: none; display: flex; flex-direction: column; gap: 0; }
.nav li {
padding: 0.25rem 0.8rem;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
transition: all 0.1s ease;
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--muted);
font-size: 0.85rem;
margin: 0;
border-top: 1px solid transparent;
}
.nav li:hover { background: rgba(255,255,255,0.05); color: var(--text); }
.nav li.active { background: rgba(56, 189, 248, 0.15); color: var(--accent); }
/* Navigation Dividers */
.nav li:not(.sub-item):not(:first-child) {
border-top: 1px solid rgba(255, 255, 255, 0.05);
margin-top: 0.2rem;
padding-top: 0.4rem;
}
.section-title {
font-size: 0.7rem;
font-weight: 800;
color: rgba(255,255,255,0.25);
margin: 0.8rem 0 0.2rem 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05rem;
list-style: none;
border-top: 1px solid rgba(255, 255, 255, 0.05);
padding-top: 0.5rem;
}
.sub-item { padding-left: 2.2rem !important; border-top: none !important; margin-top: 0 !important; }
/* Sidebar Footer */
.sidebar-footer {
display: flex;
gap: 8px;
margin-top: auto;
padding-top: 20px;
border-top: 1px solid rgba(255,255,255,0.05);
}
.sidebar-footer .action-btn { flex: 1; padding: 10px !important; }
#settingsBtn {
flex: 0 0 45px !important;
display: flex;
justify-content: center;
align-items: center;
padding: 0 !important;
font-size: 1.1rem;
}
/* Collapsed State (Desktop) */
.sidebar.collapsed { width: var(--sidebar-collapsed-width); padding: 2rem 0.5rem; }
.sidebar.collapsed .text { display: none !important; }
.sidebar.collapsed .logo { width: auto; margin-right: 0; }
.sidebar.collapsed .nav li { padding: 0.8rem 0; justify-content: center; background: none !important; border: none !important; }
.sidebar.collapsed .nav li.active { color: var(--accent); }
.sidebar.collapsed .nav li .icon { font-size: 1.2rem; margin: 0; }
.sidebar.collapsed .section-title { padding: 0.5rem 0; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); height: 1px; overflow: hidden; }
.sidebar.collapsed .sub-item { padding-left: 0 !important; }
/* Hide redundant sections in collapsed state */
.sidebar.collapsed .sidebar-section:has(#calendarHeader),
.sidebar.collapsed .sidebar-section:has(#heatmapContainer),
.sidebar.collapsed #calendarHeader,
.sidebar.collapsed #calendarContainer,
.sidebar.collapsed #heatmapContainer {
display: none !important;
}
/* Remove boxes from buttons in collapsed state */
.sidebar.collapsed .action-btn {
background: none !important;
border: none !important;
box-shadow: none !important;
justify-content: center !important;
padding: 10px 0 !important;
}
.sidebar.collapsed .sidebar-footer { padding: 10px 0; justify-content: center; display: flex; flex-direction: column; align-items: center; border-top: 1px solid rgba(255,255,255,0.05);}
.sidebar.collapsed #logoutBtn { padding: 8px !important; justify-content: center; }
.sidebar.collapsed #settingsBtn { background: none !important; border: none !important; }
/* Tooltips for Collapsed Sidebar */
.sidebar.collapsed [data-tooltip] { position: relative; }
.sidebar.collapsed [data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
left: 80px;
top: 50%;
transform: translateY(-50%);
background: rgba(15, 23, 42, 0.9);
color: white;
padding: 6px 12px;
border-radius: 6px;
font-size: 0.8rem;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: all 0.2s ease;
z-index: 1000;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.sidebar.collapsed [data-tooltip]:hover::after {
opacity: 1;
left: 70px;
}
+29
View File
@@ -0,0 +1,29 @@
html { font-size: 15px; }
:root {
--bg: #0f172a;
--sidebar: rgba(30, 41, 59, 0.7);
--card: rgba(30, 41, 59, 0.85);
--text: #f8fafc;
--muted: #94a3b8;
--accent: #38bdf8;
--accent-hover: #0ea5e9;
--ai-accent: #8b5cf6;
--encrypted-border: #00f3ff;
--sidebar-width: 260px;
--sidebar-collapsed-width: 70px;
--font: 'Inter', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background-color: var(--bg);
background-image: radial-gradient(circle at 15% 50%, rgba(56, 189, 248, 0.05), transparent 25%),
radial-gradient(circle at 85% 30%, rgba(139, 92, 246, 0.05), transparent 25%);
color: var(--text);
font-family: var(--font);
display: flex;
height: 100vh;
overflow: hidden;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

+112
View File
@@ -0,0 +1,112 @@
/**
* 앱의 전역 상태 및 데이터 관리 엔진 (State Management & Core Services)
*/
import { API } from './api.js';
import { UI } from './ui.js';
import { CalendarManager } from './components/CalendarManager.js';
import { HeatmapManager } from './components/HeatmapManager.js';
export const AppService = {
state: {
memosCache: [],
currentFilterGroup: 'all',
currentFilterDate: null,
currentSearchQuery: '',
offset: 0,
limit: 20,
hasMore: true,
isLoading: false
},
/**
* 필터 상태 초기화 및 데이터 첫 페이지 로딩
*/
async refreshData(onUpdateSidebar) {
this.state.offset = 0;
this.state.memosCache = [];
this.state.hasMore = true;
this.state.isLoading = false;
// 히트맵 데이터 새로고침
if (HeatmapManager && HeatmapManager.refresh) {
HeatmapManager.refresh();
}
await this.loadMore(onUpdateSidebar, false);
},
/**
* 다음 페이지 데이터를 가져와 병합
*/
async loadMore(onUpdateSidebar, isAppend = true) {
if (this.state.isLoading || !this.state.hasMore) return;
this.state.isLoading = true;
// UI.showLoading(true)는 호출부에서 관리하거나 여기서 직접 호출 가능
try {
const filters = {
group: this.state.currentFilterGroup,
date: this.state.currentFilterDate,
query: this.state.currentSearchQuery,
offset: this.state.offset,
limit: this.state.limit
};
const newMemos = await API.fetchMemos(filters);
if (newMemos.length < this.state.limit) {
this.state.hasMore = false;
}
if (isAppend) {
this.state.memosCache = [...this.state.memosCache, ...newMemos];
} else {
this.state.memosCache = newMemos;
}
window.allMemosCache = this.state.memosCache;
this.state.offset += newMemos.length;
// 캘린더 점 표시는 첫 로드 시에면 하면 부족할 수 있으므로,
// 필요 시 전체 데이터를 새로 고침하는 별도 API가 필요할 수 있음.
// 여기서는 현재 캐시된 데이터 기반으로 업데이트.
CalendarManager.updateMemoDates(this.state.memosCache);
if (onUpdateSidebar) {
onUpdateSidebar(this.state.memosCache, this.state.currentFilterGroup);
}
UI.setHasMore(this.state.hasMore);
UI.renderMemos(newMemos, {}, window.memoEventHandlers, isAppend);
} catch (err) {
console.error('[AppService] loadMore failed:', err);
} finally {
this.state.isLoading = false;
}
},
/**
* 필터 상태를 변경하고 데이터 초기화 후 다시 로딩
*/
async setFilter({ group, date, query }, onUpdateSidebar) {
let changed = false;
if (group !== undefined && this.state.currentFilterGroup !== group) {
this.state.currentFilterGroup = group;
changed = true;
}
if (date !== undefined && this.state.currentFilterDate !== date) {
this.state.currentFilterDate = date;
changed = true;
}
if (query !== undefined && this.state.currentSearchQuery !== query) {
this.state.currentSearchQuery = query;
changed = true;
}
if (changed) {
await this.refreshData(onUpdateSidebar);
}
}
};
+78
View File
@@ -0,0 +1,78 @@
/**
* 백엔드 API와의 통신을 관리하는 모듈
*/
export const API = {
async request(url, options = {}) {
const res = await fetch(url, options);
if (res.status === 401) {
window.location.href = '/login';
return;
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Request failed: ${res.statusText}`);
}
return await res.json();
},
async fetchMemos(filters = {}) {
const { limit = 20, offset = 0, group = 'all', query = '' } = filters;
const params = new URLSearchParams({ limit, offset, group, query });
return await this.request(`/api/memos?${params.toString()}`);
},
async fetchHeatmapData(days = 365) {
return await this.request(`/api/stats/heatmap?days=${days}`);
},
async saveMemo(payload, id = null) {
const url = id ? `/api/memos/${id}` : '/api/memos';
return await this.request(url, {
method: id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
},
async decryptMemo(id, password) {
return await this.request(`/api/memos/${id}/decrypt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
},
async deleteMemo(id) {
return await this.request(`/api/memos/${id}`, { method: 'DELETE' });
},
async triggerAI(id) {
return await this.request(`/api/memos/${id}/analyze`, { method: 'POST' });
},
async fetchAssets() {
return await this.request('/api/assets');
},
async uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
return await this.request('/api/upload', { method: 'POST', body: formData });
},
async deleteAttachment(filename) {
return await this.request(`/api/attachments/${filename}`, { method: 'DELETE' });
},
// 설정 관련
fetchSettings: async () => {
const res = await fetch('/api/settings');
return await res.json();
},
saveSettings: async (data) => {
const res = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return await res.json();
}
};
+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();
}
};
+190
View File
@@ -0,0 +1,190 @@
import { API } from './api.js';
import { renderAttachmentBox } from './components/AttachmentBox.js';
import { SlashCommand } from './components/SlashCommand.js';
import { I18nManager } from './utils/I18nManager.js';
export const EditorManager = {
editor: null,
attachedFiles: [], // 현재 에디터에 첨부된 파일들
sessionFiles: new Set(), // 이번 세션에 새로 추가된 파일 트래킹 (취소 시 삭제용)
init(elSelector, onCtrlEnter) {
const isMobile = window.innerWidth <= 768;
// --- 플러그인 설정 (글자 색상) ---
const colorPlugin = (window.toastui && window.toastui.EditorPluginColorSyntax) ||
(window.toastui && window.toastui.Editor && window.toastui.Editor.plugin && window.toastui.Editor.plugin.colorSyntax);
const plugins = (typeof colorPlugin === 'function') ? [colorPlugin] : [];
this.editor = new toastui.Editor({
el: document.querySelector(elSelector),
height: '100%',
initialEditType: 'wysiwyg',
previewStyle: isMobile ? 'tab' : 'vertical',
theme: 'dark',
placeholder: I18nManager.t('composer_placeholder'),
plugins: plugins,
toolbarItems: isMobile ? [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task'],
['table', 'image', 'link'],
['code', 'codeblock']
] : [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task', 'indent', 'outdent'],
['table', 'image', 'link'],
['code', 'codeblock'],
['scrollSync']
],
hooks: {
addImageBlobHook: async (blob, callback) => {
try {
const data = await API.uploadFile(blob);
if (data.url) {
const filename = data.url.split('/').pop();
callback(`/api/download/${filename}`, data.name || 'image');
this.attachedFiles.push({
filename: filename,
original_name: data.name || 'image',
file_type: blob.type
});
this.sessionFiles.add(filename);
this.refreshAttachmentUI();
}
} catch (err) { alert(err.message); }
}
}
});
// --- 키보드 단축키 시스템 ---
const editorEl = document.querySelector(elSelector);
// Ctrl+Shift 조합 단축키 맵 (toolbar 메뉴 대체)
const shortcutMap = {
'x': 'taskList', // Ctrl+Shift+X : 체크박스(Task) 토글
'u': 'bulletList', // Ctrl+Shift+U : 순서 없는 목록
'o': 'orderedList', // Ctrl+Shift+O : 순서 있는 목록
'q': 'blockQuote', // Ctrl+Shift+Q : 인용 블록
'k': 'codeBlock', // Ctrl+Shift+K : 코드 블록
'l': 'thematicBreak', // Ctrl+Shift+L : 수평선(구분선)
']': 'indent', // Ctrl+Shift+] : 들여쓰기
'[': 'outdent', // Ctrl+Shift+[ : 내어쓰기
};
editorEl.addEventListener('keydown', (e) => {
// 1. Ctrl+Enter → 저장
if (onCtrlEnter && e.ctrlKey && !e.shiftKey && (e.key === 'Enter' || e.keyCode === 13)) {
onCtrlEnter();
return;
}
// 2. Ctrl+Shift+[Key] → toolbar 명령 실행
if (e.ctrlKey && e.shiftKey) {
const cmd = shortcutMap[e.key.toLowerCase()];
if (cmd) {
e.preventDefault();
e.stopPropagation();
this.editor.exec(cmd);
}
}
}, true); // capture 단계에서 잡아서 에디터 내부 이벤트보다 먼저 처리
// --- 슬래시 명령(/) 팝업 초기화 ---
SlashCommand.init(this.editor, editorEl);
return this.editor;
},
setAttachedFiles(files) {
console.log('[Editor] Loading attachments:', files);
this.attachedFiles = (files || []).map(f => ({
filename: f.filename || f.file_name,
original_name: f.original_name || f.name || 'file',
file_type: f.file_type || f.type || ''
}));
this.sessionFiles.clear(); // 기존 파일을 로드할 때는 세션 트래킹 초기화 (기존 파일은 삭제 대상 제외)
this.refreshAttachmentUI();
},
refreshAttachmentUI() {
const container = document.getElementById('editorAttachments');
if (!container) {
console.warn('[Editor] #editorAttachments element not found in DOM!');
return;
}
console.log('[Editor] Refreshing UI with:', this.attachedFiles);
container.innerHTML = renderAttachmentBox(this.attachedFiles);
},
bindDropEvent(wrapperSelector, onDropComplete) {
const wrapper = document.querySelector(wrapperSelector);
wrapper.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); });
wrapper.addEventListener('drop', async (e) => {
e.preventDefault(); e.stopPropagation();
const files = e.dataTransfer.files;
if (!files || files.length === 0) return;
// 에디터가 닫혀있다면 상위에서 열어줘야 함
onDropComplete(true);
for (let file of files) {
try {
const data = await API.uploadFile(file);
if (data.url) {
const filename = data.url.split('/').pop();
const isImg = ['png','jpg','jpeg','gif','webp','svg'].includes(data.ext?.toLowerCase());
const name = data.name || 'file';
// Ensure editor is focused before inserting
this.editor.focus();
if (isImg) {
this.editor.exec('addImage', { altText: name, imageUrl: data.url });
}
// 공통: 첨부 파일 목록에 추가 및 UI 갱신
this.attachedFiles.push({
filename: filename,
original_name: name,
file_type: file.type
});
this.sessionFiles.add(filename); // 세션 트래킹 추가
this.refreshAttachmentUI();
}
} catch (err) { console.error(err); }
}
});
},
getAttachedFilenames() {
return this.attachedFiles.map(f => f.filename);
},
/**
* 취소(삭제) 시 세션 동안 추가된 파일들을 서버에서 지움
*/
async cleanupSessionFiles() {
if (this.sessionFiles.size === 0) return;
console.log(`[Editor] Cleaning up ${this.sessionFiles.size} session files...`);
const filesToDelete = Array.from(this.sessionFiles);
for (const filename of filesToDelete) {
try {
await API.deleteAttachment(filename);
} catch (err) {
console.error(`Failed to delete session file ${filename}:`, err);
}
}
this.sessionFiles.clear();
},
getMarkdown() { return this.editor.getMarkdown().trim(); },
setMarkdown(md) { this.editor.setMarkdown(md); },
focus() { this.editor.focus(); }
};
+222
View File
@@ -0,0 +1,222 @@
/**
* UI 렌더링 및 이벤트를 관리하는 오케스트레이터 (Orchestrator)
*/
import { API } from './api.js';
import { createMemoCardHtml } from './components/MemoCard.js';
import { renderGroupList } from './components/SidebarUI.js';
import { ThemeManager } from './components/ThemeManager.js';
import { ModalManager } from './components/ModalManager.js';
import { I18nManager } from './utils/I18nManager.js';
const DOM = {
memoGrid: document.getElementById('memoGrid'),
groupList: document.getElementById('groupList'),
modal: document.getElementById('memoModal'),
loadingOverlay: document.getElementById('loadingOverlay'),
searchInput: document.getElementById('searchInput'),
sidebar: document.getElementById('sidebar'),
systemNav: document.getElementById('systemNav'),
scrollSentinel: document.getElementById('scrollSentinel')
};
export const UI = {
/**
* 사이드바 및 로그아웃 버튼 초기화
*/
initSidebarToggle() {
const toggle = document.getElementById('sidebarToggle');
const sidebar = DOM.sidebar;
const overlay = document.getElementById('sidebarOverlay');
const logoutBtn = document.getElementById('logoutBtn');
if (toggle && sidebar) {
const isCollapsed = localStorage.getItem('sidebarCollapsed') === 'true';
if (isCollapsed) {
sidebar.classList.add('collapsed');
const calendar = document.getElementById('calendarContainer');
if (calendar) calendar.style.display = 'none';
}
const toggleSidebar = () => {
const isMobile = window.innerWidth <= 768;
if (isMobile) {
sidebar.classList.toggle('mobile-open');
overlay.style.display = sidebar.classList.contains('mobile-open') ? 'block' : 'none';
} else {
sidebar.classList.toggle('collapsed');
const collapsed = sidebar.classList.contains('collapsed');
localStorage.setItem('sidebarCollapsed', collapsed);
const calendar = document.getElementById('calendarContainer');
if (calendar) calendar.style.display = collapsed ? 'none' : 'block';
}
};
toggle.onclick = toggleSidebar;
const mobileBtn = document.getElementById('mobileMenuBtn');
if (mobileBtn) mobileBtn.onclick = toggleSidebar;
if (overlay) {
overlay.onclick = () => {
sidebar.classList.remove('mobile-open');
overlay.style.display = 'none';
};
}
}
if (logoutBtn) {
logoutBtn.onclick = () => {
if (confirm(I18nManager.t('msg_logout_confirm'))) {
window.location.href = '/logout';
}
};
}
},
/**
* 환경 설정 및 테마 엔진 초기화 (ThemeManager 위임)
*/
async initSettings() {
return await ThemeManager.initSettings();
},
/**
* 무한 스크롤 초기화
*/
initInfiniteScroll(onLoadMore) {
if (!DOM.scrollSentinel) return;
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
onLoadMore();
}
}, { threshold: 0.1 });
observer.observe(DOM.scrollSentinel);
},
/**
* 사이드바 시스템 고정 메뉴 상태 갱신
*/
updateSidebar(memos, activeGroup, onGroupClick) {
if (!DOM.systemNav) return;
DOM.systemNav.querySelectorAll('li').forEach(li => {
const group = li.dataset.group;
li.className = (group === activeGroup) ? 'active' : '';
li.onclick = () => onGroupClick(group);
});
},
/**
* 메모 목록 메인 렌더링 (서버 사이드 필터링 결과 기반)
*/
renderMemos(memos, filters = {}, handlers, isAppend = false) {
if (!isAppend) {
DOM.memoGrid.innerHTML = '';
}
if (!memos || memos.length === 0) {
if (!isAppend) {
DOM.memoGrid.innerHTML = `<div style="grid-column: 1/-1; text-align: center; padding: 50px; color: var(--muted);">${I18nManager.t('label_no_results')}</div>`;
}
return;
}
memos.forEach(memo => {
const { className, style, innerHtml } = createMemoCardHtml(memo, memo.status === 'done');
const card = document.createElement('div');
card.className = className;
card.dataset.id = memo.id; // ID 저장
if (style) card.setAttribute('style', style);
card.innerHTML = innerHtml;
card.style.cursor = 'pointer';
card.title = I18nManager.t('tooltip_edit_hint');
card.onclick = (e) => {
// 버튼(삭제, 핀 등) 클릭 시에는 무시
if (e.target.closest('.action-btn')) return;
if (e.altKey) {
// Alt + 클릭: 즉시 수정 모드
handlers.onEdit(memo.id);
} else {
// 일반 클릭: 상세 모달 열기
this.openMemoModal(memo.id, window.allMemosCache || memos);
}
};
DOM.memoGrid.appendChild(card);
// 신규 카드에만 이벤트 바인딩
this.bindCardEventsToElement(card, handlers);
});
if (DOM.scrollSentinel) {
DOM.scrollSentinel.innerText = I18nManager.t('msg_loading');
}
},
/**
* 특정 요소(카드) 내부에 이벤트 바인딩
*/
bindCardEventsToElement(card, handlers) {
const id = card.dataset.id;
const bind = (selector, handler) => {
const btn = card.querySelector(selector);
if (btn) {
btn.onclick = (e) => {
e.stopPropagation();
handler(id);
};
}
};
bind('.edit-btn', handlers.onEdit);
bind('.delete-btn', handlers.onDelete);
bind('.ai-btn', handlers.onAI);
bind('.toggle-pin', handlers.onTogglePin);
bind('.toggle-status', handlers.onToggleStatus);
bind('.link-item', (linkId) => this.openMemoModal(linkId, window.allMemosCache || []));
bind('.unlock-btn', handlers.onUnlock);
},
/**
* 모달 열기 위임 (ModalManager 위임)
*/
openMemoModal(id, memos) {
ModalManager.openMemoModal(id, memos);
},
showLoading(show) {
DOM.loadingOverlay.style.display = show ? 'flex' : 'none';
if (DOM.scrollSentinel) {
DOM.scrollSentinel.style.display = show ? 'none' : 'flex';
}
},
setHasMore(hasMore) {
if (DOM.scrollSentinel) {
DOM.scrollSentinel.style.visibility = hasMore ? 'visible' : 'hidden';
DOM.scrollSentinel.innerText = hasMore ? I18nManager.t('msg_loading') : I18nManager.t('msg_last_memo');
}
}
};
/**
* 전역 파일 다운로드 함수 (항상 전역 스코프 유지)
*/
window.downloadFile = async function(filename, originalName) {
try {
const res = await fetch(`/api/download/${filename}`);
if (!res.ok) {
if (res.status === 403) alert(I18nManager.t('msg_permission_denied'));
else alert(`${I18nManager.t('msg_download_failed')}: ${res.statusText}`);
return;
}
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = originalName;
document.body.appendChild(a); a.click();
window.URL.revokeObjectURL(url); document.body.removeChild(a);
} catch (err) { alert(`${I18nManager.t('msg_download_error')}: ` + err.message); }
};
+43
View File
@@ -0,0 +1,43 @@
import { I18nManager } from './utils/I18nManager.js';
/**
* 유틸리티 기능을 담은 모듈
*/
/**
* HTML 특수 문자를 이스케이프 처리합니다.
*/
export function escapeHTML(str) {
if (!str) return '';
const charMap = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;'
};
return str.replace(/[&<>'"]/g, t => charMap[t] || t);
}
/**
* [[#ID]] 형태의 내부 링크를 HTML 링크로 변환합니다.
*/
export function parseInternalLinks(text, onLinkClick) {
if (!text) return '';
// 이 함수는 단순히 텍스트만 변환하며, 이벤트 바인딩은 UI 모듈에서 수행합니다.
return text.replace(/\[\[#(\d+)\]\]/g, (match, id) => {
const prefix = I18nManager.t('label_memo_id_prefix');
return `<a href="javascript:void(0)" class="internal-link" data-id="${id}">${prefix}${id}</a>`;
});
}
/**
* HTML 내의 상대 경로 이미지 src를 서버 API 경로(/api/download/...)로 변환합니다.
*/
export function fixImagePaths(html) {
if (!html) return '';
// src="image.png" 같이 상대 경로로 시작하는 경우만 가로채서 API 경로로 변경
return html.replace(/<img\s+src="([^"\/][^":]+)"/g, (match, filename) => {
return `<img src="/api/download/${filename}"`;
});
}
+10
View File
@@ -0,0 +1,10 @@
/**
* 전역 시스템 상수 (Global System Constants)
*/
export const Constants = {
GROUPS: {
DEFAULT: 'default',
FILES: 'files',
DONE: 'done'
}
};
+65
View File
@@ -0,0 +1,65 @@
/**
* 커스텀 i18n 매니저 (Custom i18n Manager)
* - 서버 설정에 따라 locale 파일을 로드하고 화면을 번역합니다.
*/
export const I18nManager = {
localeData: {},
currentLang: 'en',
/**
* 초기화 및 언어 팩 로드
*/
async init(lang = 'en') {
this.currentLang = lang;
try {
const res = await fetch(`/static/locales/${lang}.json?v=2.2`);
if (!res.ok) throw new Error(`Locale ${lang} not found`);
this.localeData = await res.json();
console.log(`🌐 i18n: Language [${lang}] loaded successfully.`);
// 초기 로드 시 한 번 전체 적용
this.applyTranslations();
} catch (err) {
console.error('❌ i18n Load Error:', err);
// 한국어 로드 실패 시에도 영어로 폴백 시도 가능
}
},
/**
* 특정 키에 해당하는 번역 텍스트 또는 배열 반환
*/
t(key) {
// 객체 깊은 참조 지원 (예: "groups.done")
const value = key.split('.').reduce((obj, k) => (obj && obj[k]), this.localeData);
return value !== undefined ? value : key; // 없으면 키 자체 반환
},
/**
* 화면 내 i18n 관련 모든 속성을 번역
*/
applyTranslations() {
// 1. 일반 텍스트 번역
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.dataset.i18n;
el.textContent = this.t(key);
});
// 2. Placeholder 번역
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.dataset.i18nPlaceholder;
el.placeholder = this.t(key);
});
// 3. Title (Browser Tooltip) 번역
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.dataset.i18nTitle;
el.title = this.t(key);
});
// 4. Custom Tooltip (data-tooltip) 번역
document.querySelectorAll('[data-i18n-tooltip]').forEach(el => {
const key = el.dataset.i18nTooltip;
el.setAttribute('data-tooltip', this.t(key));
});
}
};
+133
View File
@@ -0,0 +1,133 @@
{
"app_name": "Brain Dogfood",
"app_tagline": "Welcome to your intelligent knowledge base.",
"nav_all": "All Knowledge",
"nav_files": "Files",
"nav_done": "Done",
"nav_explorer": "Knowledge Explorer",
"nav_calendar": "Calendar",
"nav_nebula": "Knowledge Nebula",
"nav_logout": "Logout",
"nav_settings": "Settings",
"nav_toggle": "Toggle Sidebar",
"search_placeholder": "Search memos... (Title, Content, Tag)",
"composer_placeholder": "Leave a fragment of knowledge...",
"composer_placeholder_trigger": "Capture knowledge or drop files...",
"composer_title": "Title",
"composer_group": "Group",
"composer_tags": "Tags (comma separated)",
"composer_save": "Save Memo",
"composer_discard": "Discard (Delete)",
"composer_encrypt": "Encrypt",
"composer_password": "Password",
"tooltip_fold": "Fold Window (Preserve Content)",
"settings_title": "⚙️ Settings",
"settings_bg": "Background Color",
"settings_sidebar": "Sidebar Color",
"settings_card": "Memo Card Color",
"settings_security": "Security Border Color",
"settings_ai_accent": "AI Accent Color",
"settings_ai_enable": "Enable AI Features",
"settings_lang": "Language",
"settings_save": "Save Settings",
"settings_reset": "Reset",
"settings_close": "Close",
"msg_logout_confirm": "Are you sure you want to log out completely?",
"msg_delete_confirm": "Are you sure you want to delete this memo? This cannot be undone.",
"msg_save_success": "Saved successfully!",
"msg_settings_saved": "🎨 Settings have been saved to the server!",
"msg_ai_loading": "Gemini AI is analyzing the memo...",
"msg_encrypted_locked": "🚫 Encryption detected. Decrypt first to modify.",
"msg_auth_failed": "Invalid credentials. Please try again.",
"msg_network_error": "Network instability or server error occurred.",
"msg_confirm_discard": "Discard all current content and delete uploaded files from the server?",
"msg_alert_password_required": "A password is required to encrypt this knowledge.",
"msg_draft_restore_confirm": "📝 There is an auto-saved draft.\nTitle: \"{title}\"\nWould you like to restore it?",
"title_pin": "Pin to Top",
"title_done": "Mark as Done",
"title_undo": "Undo Done",
"title_ai": "AI Analysis",
"title_edit": "Edit",
"title_delete": "Delete",
"btn_unlock": "Unlock",
"label_mentioned": "Mentioned In",
"label_linked_memo": "Linked Memo",
"label_no_results": "No results found.",
"label_memo_id_prefix": "Memo #",
"tooltip_edit_hint": "Alt + Click: Quick Edit",
"prompt_password": "Enter password to decrypt this knowledge:",
"msg_loading": "Loading more knowledge...",
"msg_last_memo": "This is the last piece of knowledge.",
"msg_permission_denied": "🚫 Access Denied. Decrypt the knowledge first.",
"msg_download_failed": "❌ Download failed",
"msg_download_error": "📦 Error during download",
"login_title": "Secure Login",
"login_welcome": "Welcome to your intelligent knowledge base.",
"login_id": "Auth ID",
"login_pw": "Password",
"login_btn": "Enter System",
"msg_authenticating": "Authenticating...",
"drawer_title_groups": "📁 Groups",
"drawer_title_tags": "🏷️ Tags",
"label_untitled": "Untitled",
"label_ai_summary": "AI Summary",
"label_heatmap_title": "Knowledge Growth",
"label_more": "More",
"label_less": "Less",
"tooltip_heatmap_stat": "{date}: {count} items",
"label_asset_management": "📁 Asset Management",
"label_asset_hint": "Click: Go to memo / Alt+Click: Download",
"label_no_assets": "No files uploaded yet.",
"label_memo_ref": "Memo: ",
"label_no_memo_ref": "No linked memo",
"label_group_explorer": "📁 Group Explorer",
"label_tag_explorer": "🏷️ Tag Explorer",
"label_last_updated": "Last updated: ",
"shortcuts_label": "⌨️ Shortcuts",
"shortcut_save": "Save",
"shortcut_new": "New",
"shortcut_nebula": "Nebula",
"shortcut_slash": "Slash commands",
"shortcut_edit": "Quick Edit",
"slash": {
"task": "Task List",
"bullet": "Bullet List",
"number": "Ordered List",
"quote": "Block Quote",
"line": "Divider",
"code": "Code Block",
"h1": "Heading 1",
"h2": "Heading 2",
"h3": "Heading 3",
"ai_summary": "AI Summary",
"ai_tags": "AI Tags"
},
"calendar_months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
"calendar_days": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
"date_month_year": "{month} {year}",
"heatmap_ranges": {
"365": "1 Year",
"180": "6 Months",
"90": "3 Months",
"30": "1 Month"
},
"label_select_range": "Select Range",
"groups": {
"all": "All Knowledge",
"default": "Default",
"files": "Files",
"done": "Done"
}
}
+132
View File
@@ -0,0 +1,132 @@
{
"app_name": "뇌사료",
"app_tagline": "지식 창고에 오신 것을 환영합니다.",
"nav_all": "전체 지식",
"nav_files": "파일 모음",
"nav_done": "완료 모음",
"nav_explorer": "지식 탐색기",
"nav_calendar": "달력 탐색",
"nav_nebula": "지식 맵 보기",
"nav_logout": "로그아웃",
"nav_settings": "환경 설정",
"nav_toggle": "사이드바 토글",
"search_placeholder": "메모 검색... (제목, 내용, 태그)",
"composer_placeholder": "지식의 파편을 남겨주세요...",
"composer_placeholder_trigger": "메모를 기록하거나 파일을 던져보세요...",
"composer_title": "제목",
"composer_group": "그룹명",
"composer_tags": "태그 (쉼표로 구분)",
"composer_save": "메모 저장",
"composer_discard": "취소 (삭제)",
"composer_encrypt": "암호화",
"composer_password": "비밀번호",
"tooltip_fold": "창 접기 (내용 보존)",
"settings_title": "⚙️ 환경 설정",
"settings_bg": "전체 배경색",
"settings_sidebar": "사이드바 색상",
"settings_card": "메모지 색상",
"settings_security": "보안 테두리색",
"settings_ai_accent": "AI 분석 강조색",
"settings_ai_enable": "AI 기능 활성화",
"settings_lang": "언어 설정",
"settings_save": "저장",
"settings_reset": "초기화",
"settings_close": "닫기",
"msg_logout_confirm": "완전하게 로그아웃하시겠습니까?",
"msg_delete_confirm": "이 메모를 정말 삭제할까요? 되돌릴 수 없습니다.",
"msg_save_success": "저장되었습니다!",
"msg_settings_saved": "🎨 테마 설정이 서버에 저장되었습니다!",
"msg_ai_loading": "Gemini AI가 메모를 분석 중입니다...",
"msg_encrypted_locked": "🚫 암호화된 메모입니다. 먼저 해독하세요.",
"msg_auth_failed": "올바른 자격 증명이 아닙니다. 다시 시도해 주세요.",
"msg_network_error": "네트워크 불안정 또는 서버 오류가 발생했습니다.",
"msg_confirm_discard": "작성 중인 내용을 모두 지우고 업로드한 파일도 서버에서 삭제할까요?",
"msg_alert_password_required": "암호화하려면 비밀번호를 입력해야 합니다.",
"msg_draft_restore_confirm": "📝 임시 저장된 메모가 있습니다.\n제목: \"{title}\"\n복원하시겠습니까?",
"title_pin": "중요 (상단 고정)",
"title_done": "완료 처리",
"title_undo": "다시 활성화",
"title_ai": "AI 분석",
"title_edit": "수정",
"title_delete": "삭제",
"btn_unlock": "해독하기",
"label_mentioned": "언급됨",
"label_no_results": "조회 결과가 없습니다.",
"label_memo_id_prefix": "메모 #",
"tooltip_edit_hint": "Alt + 클릭: 즉시 수정",
"prompt_password": "이 지식을 해독할 비밀번호를 입력하세요:",
"msg_loading": "더 많은 지식을 불러오는 중...",
"msg_last_memo": "마지막 지식입니다.",
"msg_permission_denied": "🚫 접근 권한 부족. 먼저 지식을 해독하세요.",
"msg_download_failed": "❌ 다운로드 실패",
"msg_download_error": "📦 다운로드 중 오류",
"login_title": "보안 로그인",
"login_welcome": "지능형 지식 창고에 오신 것을 환영합니다.",
"login_id": "인증 아이디",
"login_pw": "비밀번호",
"login_btn": "보안 시스템 접속",
"msg_authenticating": "인증 중...",
"drawer_title_groups": "📁 그룹",
"drawer_title_tags": "🏷️ 태그",
"label_untitled": "무제",
"label_ai_summary": "AI 요약",
"label_heatmap_title": "지식 성장",
"label_more": "많음",
"label_less": "적음",
"tooltip_heatmap_stat": "{date}: {count}개의 지식",
"label_asset_management": "📁 전체 첨부파일 관리",
"label_asset_hint": "클릭: 해당 메모로 이동 / Alt+클릭: 미리보기",
"label_no_assets": "아직 업로드된 파일이 없습니다.",
"label_memo_ref": "메모: ",
"label_no_memo_ref": "연결된 메모 없음",
"label_group_explorer": "📁 그룹별 탐색",
"label_tag_explorer": "🏷️ 태그별 탐색",
"label_last_updated": "마지막 수정: ",
"shortcuts_label": "⌨️ 단축키",
"shortcut_save": "저장",
"shortcut_new": "새 메모",
"shortcut_nebula": "네뷸라",
"shortcut_slash": "슬래시 명령",
"shortcut_edit": "즉시 수정",
"slash": {
"task": "체크박스",
"bullet": "목록",
"number": "번호 목록",
"quote": "인용",
"line": "구분선",
"code": "코드 블록",
"h1": "제목 1",
"h2": "제목 2",
"h3": "제목 3",
"ai_summary": "AI 요약",
"ai_tags": "AI 태그 추출"
},
"calendar_months": ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"],
"calendar_days": ["일", "월", "화", "수", "목", "금", "토"],
"date_month_year": "{year}년 {month}",
"heatmap_ranges": {
"365": "1년",
"180": "6개월",
"90": "3개월",
"30": "1개월"
},
"label_select_range": "기간 선택",
"groups": {
"all": "전체 지식",
"default": "기본",
"files": "파일모음",
"done": "완료모음"
}
}
+25
View File
@@ -0,0 +1,25 @@
/* 🧠 뇌사료(Memo Server) Main Stylesheet
- Modularized Architecture (v4.0)
*/
/* 1. Global Variables & Reset */
@import url('./css/variables.css');
/* 2. Base Layout Shell */
@import url('./css/layout.css');
/* 3. Core Components Styles */
@import url('./css/sidebar.css');
@import url('./css/components/memo.css');
@import url('./css/components/editor.css');
@import url('./css/components/modals.css');
/* 4. Visualization & Navigation (Calendar, Drawer, Map) */
@import url('./css/components/visualization.css');
@import url('./css/components/heatmap.css');
/* 5. Slash Command Popup */
@import url('./css/components/slash-command.css');
/* 6. Mobile Responsive Overrides */
@import url('./css/mobile.css');