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
+13
View File
@@ -0,0 +1,13 @@
# 뇌사료(Brain Dogfood) Environment Variables Template
# Flask Security
SECRET_KEY=your_super_secret_key_here
SESSION_LIFETIME_DAYS=30
# Authentication
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin
# AI Features (Optional)
GEMINI_API_KEY=your_google_gemini_api_key_here
GEMINI_MODEL=gemini-1.5-flash
+10
View File
@@ -0,0 +1,10 @@
# Brain Dogfood Public Repo Gitignore
.env
config.json
memos.db
__pycache__/
*.pyc
logs/
data/
.vscode/
.idea/
+108
View File
@@ -0,0 +1,108 @@
[한국어](#한국어) | [English](#english)
<br/>
<div align="center">
<img src="docs/img/main.png" alt="Brain Dogfood Dashboard" width="100%">
<h1>🧠 뇌사료 (Brain Dogfood)</h1>
<p><b>지식을 기록하는 습관을 넘어, 지능형 유기체로 성장하는 나만의 지식 창고</b></p>
<p>Minimalist, AI-powered, Privacy-first Knowledge Server</p>
</div>
---
> [!IMPORTANT]
> **보안 주의사항 (Security Notice)**
> - 기본 관리자 계정은 아이디: `admin` / 비밀번호: `.env` 파일에서 본인이 설정한 값입니다.
> - 최초 로그인 후, 혹은 서버 실행 전 **`.env` 파일에서 `ADMIN_USERNAME`과 `ADMIN_PASSWORD`를 반드시 본인만의 정보로 수정**하세요. 수정하지 않을 경우 보안에 매우 취약해질 수 있습니다.
> [!NOTE]
> **AI 기능은 선택 사항입니다 (AI is Optional)**
> - **Gemini API 키가 없어도** 뇌사료의 핵심 기능(기본 메모, 히트맵, 지식 그래프 Nebula, 개별 암호화 등)은 **모두 정상 작동**합니다.
> - AI 기능(`GEMINI_API_KEY`)은 자동 요약과 인공지능 태깅 기능을 사용할 때만 필요합니다.
---
<h2 id="한국어">📄 프로젝트 소개</h2>
**뇌사료(Brain Dogfood)**는 "내가 만든 지식은 내가 먼저 소비한다"는 철학에서 시작된 개인용 메모 서버입니다. 단순한 텍스트 기록을 넘어, AI가 당신의 지식을 분석하고 유기적인 그래프(Nebula)로 연결하여 새로운 통찰을 제공합니다.
### ✨ 독보적인 강점
* **Intelligent Nebula**: 단순히 태그로 묶는 것이 아닙니다. D3.js 기반의 그래프 시각화를 통해 지식 간의 관계를 시각적으로 탐험하세요.
* **AI Insight Hub (Optional)**: Gemini 2.0 Flash가 모든 메모를 실시간으로 요약하고 최적의 태그를 제안합니다. 당신은 기록에만 집중하세요.
* **Privacy-First Security**: 메모별로 개별 암호화를 지원합니다. 서버 관리자조차도 당신의 비밀번호 없이는 지식을 엿볼 수 없습니다.
* **High-End UX**: 글래스모피즘 기반의 모던한 UI와 하이엔드 셰이더 효과, 그리고 빠른 생산성을 위한 풍부한 단축키 시스템을 제공합니다.
---
## 🆚 memos vs 뇌사료 (Comparison)
| 기능 | **memos (Open Source)** | **🧠 뇌사료 (Brain Dogfood)** |
| :--- | :--- | :--- |
| **기본 철학** | 타임라인 기반 마이크로 블로깅 | 유기적인 지식 연결 및 AI 통찰 |
| **시각화** | 단순 달력/히트맵 | **D3.js Knowledge Nebula (그래프)** |
| **AI 통합** | 외부 플러그인 의존 | **Gemini 2.0 Native 통합 (자동 요약/태그 / 선택 사항)** |
| **보안** | DB 전체 보안 | **메모별 개별 암호화 (Grain-level Security)** |
| **사용성** | 모바일 앱 위주 | **데스크탑 생산성 최적화 (Slash Commands & Shortcuts)** |
| **디자인** | 미니멀, 정적인 UI | **Modern Glassmorphism & 다이내믹 애니메이션** |
---
## ⌨️ 생산성 단축키
| 동작 | 단축키 | 설명 |
| :--- | :--- | :--- |
| **저장/수정** | `Ctrl + Enter` | 작성한 메모를 즉시 서버에 반영 |
| **새 메모** | `Ctrl + Shift + N` | 언제 어디서든 즉시 작성창 호출 |
| **슬래시 명령** | `/` | `/task`, `/ai`, `/h2` 등으로 빠른 서식 지정 |
| **지식 탐색기** | `Ctrl + Shift + E` | 저장된 지식의 구조를 한눈에 파악 |
| **즉시 수정** | `Alt + Click` | 메인 그리드에서 즉시 편집 모드 진입 |
---
## 🛠️ 시작하기
```bash
# 1. 저장소 복제 및 종속성 설치
pip install -r requirements.txt
# 2. .env.example을 .env로 복사 후 설정 수정 (필수)
cp .env.example .env
# 3. 서버 실행
python brain.py
```
*`.env` 파일에서 관리자 아이디와 비밀번호를 꼭 수정하고, 필요한 경우에만 `GEMINI_API_KEY`를 등록하세요.*
---
<h2 id="english">🌐 English Description</h2>
### What is Brain Dogfood?
**Brain Dogfood** is a minimalist yet powerful personal knowledge server built for those who value privacy and deep insights. Its not just a memo app; its an **intelligent knowledge ecosystem** that grows with you.
> [!IMPORTANT]
> **Security Notice**:
> Default credentials are set in the `.env` file. **You MUST change `ADMIN_USERNAME` and `ADMIN_PASSWORD`** in your `.env` file before running the server in a public environment.
> [!NOTE]
> **AI is Optional**:
> All core features (Memos, Heatmap, Knowledge Nebula, Encryption) work perfectly **without an AI API key**. The `GEMINI_API_KEY` is only required for automated summarization and AI tagging.
### Key Features
- **AI-Driven Insights**: Powered by Gemini 2.0 Flash for instant summarization and smart tagging (Optional).
- **Knowledge Nebula**: Explore your thoughts through a dynamic D3.js-based interactive knowledge graph.
- **Advanced Security**: Grain-level encryption for individual memos your data is for your eyes only.
- **Premium Aesthetics**: Sleek glassmorphism UI with smooth micro-animations and production-ready UX.
### Quick Start
1. Install dependencies: `pip install -r requirements.txt`
2. Create your `.env` from `.env.example` and update your master credentials.
3. Launch the server: `python brain.py` (Default port: 5050 on Windows, 5093 on Linux).
---
<div align="center">
<p>Developed with ❤️ for knowledge lovers.</p>
</div>
+108
View File
@@ -0,0 +1,108 @@
import os
import json
from flask import Flask, request, abort # type: ignore
from dotenv import load_dotenv
load_dotenv()
def create_app():
# Set folders to parent directory since app logic is now in a subfolder
template_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'templates'))
static_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'static'))
app = Flask(__name__, template_folder=template_dir, static_folder=static_dir)
app.secret_key = os.getenv('SECRET_KEY', 'dev_key')
# --- 🛡️ 보안 실드 & 로깅 설정 ---
import logging
from logging.handlers import RotatingFileHandler
log_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'logs'))
os.makedirs(log_dir, exist_ok=True)
file_handler = RotatingFileHandler(
os.path.join(log_dir, 'app.log'),
maxBytes=10*1024*1024, # 10MB
backupCount=5,
encoding='utf-8'
)
file_handler.setFormatter(logging.Formatter(
'[%(asctime)s] %(levelname)s in %(module)s: %(message)s'
))
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('🚀 뇌사료 서버 기동 - 로깅 시스템 가동')
@app.errorhandler(403)
def forbidden(e):
return "Forbidden: Suspicious activity detected. Your IP has been logged.", 403
@app.before_request
def unified_logger():
# 클라이언트 IP (Cloudflare 등을 거칠 경우 X-Forwarded-For 확인)
ip = request.headers.get('X-Forwarded-For', request.remote_addr)
path = request.path
method = request.method
params = request.query_string.decode('utf-8')
# 1. 보안 실드: 메인/로그인 페이지에 파라미터가 붙은 경우 즉시 차단
if path.rstrip('/') in ['', '/login'] and params:
log_msg = f"[SHIELD] Blocked: [{ip}] {method} {path}?{params}"
app.logger.warning(log_msg)
abort(403)
# 2. 트래픽 로깅 (정적 파일 제외)
if not path.startswith('/static/'):
log_msg = f"ACCESS: [{ip}] {method} {path}"
if params:
log_msg += f"?{params}"
app.logger.info(log_msg)
upload_folder = os.path.abspath(os.path.join(static_dir, 'uploads'))
os.makedirs(upload_folder, exist_ok=True)
app.config['UPLOAD_FOLDER'] = upload_folder
# Load config.json
config_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'config.json'))
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
app.config['UPLOAD_SECURITY'] = cfg.get('upload_security', {})
else:
app.config['UPLOAD_SECURITY'] = {'allowed_extensions': [], 'blocked_extensions': []}
# Initialize DB schema
from .database import init_db
init_db()
# Session and Security configurations
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE='Lax',
SESSION_COOKIE_SECURE=False, # Set to True in production with HTTPS
PERMANENT_SESSION_LIFETIME=3600 # 60 minutes (1 hour) session
)
@app.after_request
def add_security_headers(response):
"""보안 강화를 위한 HTTP 헤더 추가"""
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
response.headers['X-XSS-Protection'] = '1; mode=block'
# Content Security Policy (Toast UI 및 외부 CDN 허용)
# 운영 환경에 맞춰 점진적으로 강화 가능
csp = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://uicdn.toast.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://static.cloudflareinsights.com https://d3js.org; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://uicdn.toast.com; "
"font-src 'self' https://fonts.gstatic.com; "
"img-src 'self' data: blob:; "
"connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://cloudflareinsights.com https://d3js.org;"
)
response.headers['Content-Security-Policy'] = csp
return response
# Register modular blueprints
from .routes import register_blueprints
register_blueprints(app)
return app
+95
View File
@@ -0,0 +1,95 @@
import os
import json
import logging
from google import genai
from .utils.i18n import _t
# 로거 설정
logger = logging.getLogger('ai')
def analyze_memo(title, content, lang='en'):
"""
최신 google-genai SDK를 사용하여 메모 본문을 요약하고 태그를 추출합니다.
"""
api_key = os.getenv('GEMINI_API_KEY')
primary_model = os.getenv('GEMINI_MODEL', 'gemini-2.0-flash')
# 예비용 모델 리스트
fallbacks = [primary_model, 'gemini-1.5-flash-latest', 'gemini-1.5-flash']
models_to_try = list(dict.fromkeys(fallbacks))
if not api_key:
error_msg = "Gemini API key is required." if lang == 'en' else "AI 분석을 위해 Gemini API 키가 필요합니다."
return error_msg, []
client = genai.Client(api_key=api_key)
if lang == 'ko':
prompt = f"""
당신은 메모 분석 전문가입니다. 아래 메모의 제목과 내용을 읽고 다음 작업을 수행하세요:
1. 내용을 1~2문장으로 아주 간결하게 요약할 것.
2. 내용과 관련된 핵심 키워드를 태그 형태로 3~5개 추출할 것.
[제목] {title}
[내용] {content}
출력 형식(JSON):
{{
"summary": "요약 내용",
"tags": ["태그1", "태그2", "태그3"]
}}
"""
else:
prompt = f"""
You are a memo analysis expert. Read the title and content below and perform the following:
1. Summarize the content very concisely in 1-2 sentences.
2. Extract 3-5 key keywords as tags.
[Title] {title}
[Content] {content}
Output Format (JSON):
{{
"summary": "Summary text",
"tags": ["Tag1", "Tag2", "Tag3"]
}}
"""
last_error = None
for model_name in models_to_try:
try:
logger.info(f"[AI] Attempting analysis with model: {model_name} (lang={lang})")
response = client.models.generate_content(
model=model_name,
contents=prompt
)
res_text = response.text.strip()
if '```json' in res_text:
res_text = res_text.split('```json')[1].split('```')[0].strip()
elif '```' in res_text:
res_text = res_text.split('```')[1].strip()
data = json.loads(res_text)
logger.info(f"[AI] Analysis successful using {model_name}")
return data.get('summary', ''), data.get('tags', [])
except Exception as e:
last_error = e
logger.warning(f"[AI] Model {model_name} failed: {str(e)}")
if "404" in str(e):
continue
else:
break
# 모든 시도가 실패한 경우
error_header = "AI Analysis Error: " if lang == 'en' else "AI 분석 중 오류 발생: "
error_msg = f"{error_header}{str(last_error)}"
if "404" in str(last_error):
if lang == 'en':
error_msg += "\n(Note: The selected model might be expired. Check GEMINI_MODEL in .env)"
else:
error_msg += "\n(알림: 선택한 AI 모델이 만료되었을 수 있습니다. .env의 GEMINI_MODEL 설정을 확인해주세요.)"
return error_msg, []
+20
View File
@@ -0,0 +1,20 @@
import os
import functools
from flask import session, redirect, url_for, request, current_app # type: ignore
def check_auth(username, password):
"""
환경 변수에 설정된 관리자 계정 정보와 일치하는지 확인합니다.
"""
admin_user = os.getenv('ADMIN_USERNAME', 'admin')
admin_password = os.getenv('ADMIN_PASSWORD', 'admin')
return username == admin_user and password == admin_password
def login_required(view):
@functools.wraps(view)
def wrapped_view(**kwargs):
# app/routes/auth.py의 세션 키와 일치시킴 (logged_in)
if session.get('logged_in') is None:
return redirect(url_for('main.login_page'))
return view(**kwargs)
return wrapped_view
+8
View File
@@ -0,0 +1,8 @@
# System-wide Core Constants (Globalization-ready)
# System Reserved Groups
GROUP_DEFAULT = "default" # 기본
GROUP_FILES = "files" # 파일모음
GROUP_DONE = "done" # 완료모음
SYSTEM_GROUPS = [GROUP_DEFAULT, GROUP_FILES, GROUP_DONE]
+83
View File
@@ -0,0 +1,83 @@
import os
import sqlite3
# Data directory relative to this file
DB_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'data'))
os.makedirs(DB_DIR, exist_ok=True)
DB_PATH = os.path.join(DB_DIR, 'memos.db')
def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# 1. Memos Table
c.execute('''
CREATE TABLE IF NOT EXISTS memos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
content TEXT,
summary TEXT,
color TEXT DEFAULT '#2c3e50',
is_pinned BOOLEAN DEFAULT 0,
status TEXT DEFAULT 'active', -- 'active', 'done', 'archived'
group_name TEXT DEFAULT 'default',
is_encrypted BOOLEAN DEFAULT 0,
created_at TIMESTAMP,
updated_at TIMESTAMP
)
''')
try:
c.execute("ALTER TABLE memos ADD COLUMN status TEXT DEFAULT 'active'")
except sqlite3.OperationalError:
pass
try:
c.execute("ALTER TABLE memos ADD COLUMN is_encrypted BOOLEAN DEFAULT 0")
except sqlite3.OperationalError:
pass
# 2. Separate Tags Table (Normalized)
c.execute('''
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memo_id INTEGER,
name TEXT,
source TEXT, -- 'user' or 'ai'
FOREIGN KEY (memo_id) REFERENCES memos (id) ON DELETE CASCADE
)
''')
# 3. Attachments Table (Enhanced Asset Tracking)
c.execute('''
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memo_id INTEGER,
filename TEXT,
original_name TEXT,
file_type TEXT,
size INTEGER,
created_at TIMESTAMP,
FOREIGN KEY (memo_id) REFERENCES memos (id) ON DELETE SET NULL
)
''')
# 4. Memo Links Table (Backlinks)
c.execute('''
CREATE TABLE IF NOT EXISTS memo_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER,
target_id INTEGER,
FOREIGN KEY (source_id) REFERENCES memos (id) ON DELETE CASCADE,
FOREIGN KEY (target_id) REFERENCES memos (id) ON DELETE CASCADE
)
''')
conn.commit()
conn.close()
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
+16
View File
@@ -0,0 +1,16 @@
from flask import Blueprint # type: ignore
def register_blueprints(app):
from .main import main_bp
from .auth import auth_bp
from .memo import memo_bp
from .file import file_bp
from .ai import ai_bp
from .settings import settings_bp
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(memo_bp)
app.register_blueprint(file_bp)
app.register_blueprint(ai_bp)
app.register_blueprint(settings_bp)
+46
View File
@@ -0,0 +1,46 @@
import datetime
from flask import Blueprint, jsonify, current_app # type: ignore
from ..database import get_db
from ..auth import login_required
from ..ai import analyze_memo
from ..utils.i18n import _t
ai_bp = Blueprint('ai', __name__)
@ai_bp.route('/api/memos/<int:memo_id>/analyze', methods=['POST'])
@login_required
def analyze_memo_route(memo_id):
conn = get_db()
c = conn.cursor()
c.execute('SELECT title, content, is_encrypted FROM memos WHERE id = ?', (memo_id,))
memo = c.fetchone()
if not memo:
return jsonify({'error': _t('label_no_results')}), 404
if memo['is_encrypted']:
return jsonify({'error': _t('msg_encrypted_locked')}), 403
current_app.logger.info(f"AI Analysis Started: ID {memo_id}, Title: '{memo['title']}'")
lang = current_app.config.get('lang', 'en')
summary, ai_tags = analyze_memo(memo['title'], memo['content'], lang=lang)
try:
c.execute('UPDATE memos SET summary = ?, updated_at = ? WHERE id = ?',
(summary, datetime.datetime.now().isoformat(), memo_id))
c.execute("DELETE FROM tags WHERE memo_id = ? AND source = 'ai'", (memo_id,))
for tag in ai_tags:
if tag.strip():
c.execute('INSERT INTO tags (memo_id, name, source) VALUES (?, ?, ?)',
(memo_id, tag.strip(), 'ai'))
conn.commit()
current_app.logger.info(f"AI Analysis SUCCESS: ID {memo_id}, Tags extracted: {len(ai_tags)}")
return jsonify({'summary': summary, 'tags': ai_tags})
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
+22
View File
@@ -0,0 +1,22 @@
from flask import Blueprint, request, jsonify, session, redirect, url_for # type: ignore
from ..auth import check_auth
from ..utils.i18n import _t
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['POST'])
def login():
data = request.json
username = data.get('username')
password = data.get('password')
if check_auth(username, password):
session.permanent = True # Enable permanent session to use LIFETIME config
session['logged_in'] = True
return jsonify({'message': 'Logged in successfully'})
return jsonify({'error': _t('msg_auth_failed')}), 401
@auth_bp.route('/logout')
def logout():
session.pop('logged_in', None)
return redirect(url_for('main.login_page'))
+162
View File
@@ -0,0 +1,162 @@
import os
import uuid
import datetime
import mimetypes
from flask import Blueprint, request, jsonify, current_app, Response, send_from_directory, session # type: ignore
from werkzeug.utils import secure_filename # type: ignore
from urllib.parse import quote # type: ignore
from ..database import get_db
from ..auth import login_required
from ..security import encrypt_file, decrypt_file
file_bp = Blueprint('file', __name__)
@file_bp.route('/api/upload', methods=['POST'])
@login_required
def upload_file():
if 'image' not in request.files and 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
file = request.files.get('image') or request.files.get('file')
if not file or file.filename == '':
return jsonify({'error': 'No selected file'}), 400
ext = os.path.splitext(file.filename)[1].lower().replace('.', '')
sec_conf = current_app.config.get('UPLOAD_SECURITY', {})
blocked = sec_conf.get('blocked_extensions', [])
allowed = sec_conf.get('allowed_extensions', [])
if ext in blocked:
return jsonify({'error': f'Extension .{ext} is blocked for security reasons.'}), 403
if allowed and ext not in allowed:
return jsonify({'error': f'Extension .{ext} is not in the allowed list.'}), 403
unique_filename = f"{uuid.uuid4()}.{ext}"
filename = secure_filename(unique_filename)
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
# Encrypt and save
file_bytes = file.read()
encrypted_bytes = encrypt_file(file_bytes)
with open(filepath, 'wb') as f:
f.write(encrypted_bytes)
# Record attachment in DB
conn = get_db()
c = conn.cursor()
c.execute('''
INSERT INTO attachments (filename, original_name, file_type, size, created_at)
VALUES (?, ?, ?, ?, ?)
''', (filename, file.filename, ext, os.path.getsize(filepath), datetime.datetime.now().isoformat()))
conn.commit()
conn.close()
return jsonify({
'url': f"/api/download/{filename}",
'name': file.filename,
'ext': ext
})
@file_bp.route('/api/download/<filename>')
def download_file_route(filename):
filename = secure_filename(filename)
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
if not os.path.exists(filepath):
return jsonify({'error': 'File not found'}), 404
# Check security status of parent memo
conn = get_db()
c = conn.cursor()
c.execute('''
SELECT a.original_name, m.is_encrypted
FROM attachments a
LEFT JOIN memos m ON a.memo_id = m.id
WHERE a.filename = ?
''', (filename,))
row = c.fetchone()
conn.close()
# 로그인된 상태라면 암호화된 메모의 파일이라도 본인 확인이 된 것으로 간주하고 허용
is_logged_in = session.get('logged_in') is True
# 만약 메모가 암호화되어 있고, 로그인도 되어 있지 않다면 차단
if row and row['is_encrypted'] and not is_logged_in:
current_app.logger.warning(f"Access Denied: Unauthenticated access to encrypted file {filename}")
return jsonify({'error': 'Access denied. Please login to view this attachment.'}), 403
with open(filepath, 'rb') as f:
data = f.read()
decrypted = decrypt_file(data)
orig_name = row['original_name'] if row else filename
# 원본 파일명 기반으로 정확한 마임타입 추측
mime_type, _ = mimetypes.guess_type(orig_name)
if not mime_type: mime_type = 'application/octet-stream'
# 이미지인 경우 'inline'으로 설정하여 브라우저 본문 내 렌더링 허용, 그 외는 'attachment'
is_image = mime_type.startswith('image/')
disposition = 'inline' if is_image else 'attachment'
headers = {
'Content-Disposition': f"{disposition}; filename*=UTF-8''{quote(orig_name)}"
}
content_data = decrypted if decrypted is not None else data
return Response(content_data, mimetype=mime_type, headers=headers)
@file_bp.route('/api/assets', methods=['GET'])
@login_required
def get_assets():
conn = get_db()
c = conn.cursor()
# Filter out files belonging to encrypted memos
c.execute('''
SELECT a.*, m.title as memo_title
FROM attachments a
LEFT JOIN memos m ON a.memo_id = m.id
WHERE m.is_encrypted = 0 OR m.is_encrypted IS NULL
ORDER BY a.created_at DESC
''')
assets = [dict(r) for r in c.fetchall()]
conn.close()
return jsonify(assets)
@file_bp.route('/api/attachments/<filename>', methods=['DELETE'])
@login_required
def delete_attachment_route(filename):
filename = secure_filename(filename)
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
conn = get_db()
c = conn.cursor()
# 파일 정보 확인
c.execute('SELECT id, memo_id FROM attachments WHERE filename = ?', (filename,))
row = c.fetchone()
if not row:
conn.close()
return jsonify({'error': 'File not found in database'}), 404
# 보안: 메모에 이미 연결된 파일은 삭제하지 않음 (취소 시에는 아직 연결되지 않은 파일만 삭제)
# 만약 연결된 파일을 삭제하고 싶다면 별도의 로직 필요
if row['memo_id'] is not None:
conn.close()
return jsonify({'error': 'Cannot delete file already linked to a memo'}), 403
try:
# 1. DB 삭제
c.execute('DELETE FROM attachments WHERE filename = ?', (filename,))
conn.commit()
# 2. 물리 파일 삭제
if os.path.exists(filepath):
os.remove(filepath)
current_app.logger.info(f"Attachment Deleted: {filename}")
return jsonify({'message': 'File deleted successfully'})
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
+27
View File
@@ -0,0 +1,27 @@
from flask import Blueprint, render_template, redirect, url_for, session, current_app # type: ignore
from ..auth import login_required
import os
import json
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
@login_required
def index():
return render_template('index.html')
@main_bp.route('/login', methods=['GET'])
def login_page():
if 'logged_in' in session:
return redirect(url_for('main.index'))
# i18n 지원을 위해 기본 언어 전달
config_path = os.path.join(os.getcwd(), 'config.json')
lang = 'ko'
if os.path.exists(config_path):
try:
with open(config_path, 'r', encoding='utf-8') as f:
lang = json.load(f).get('lang', 'ko')
except: pass
return render_template('login.html', lang=lang)
+356
View File
@@ -0,0 +1,356 @@
import datetime
from flask import Blueprint, request, jsonify, current_app # type: ignore
from ..database import get_db
from ..auth import login_required
from ..constants import GROUP_DONE, GROUP_DEFAULT
from ..utils.i18n import _t
from ..utils import extract_links
from ..security import encrypt_content, decrypt_content
memo_bp = Blueprint('memo', __name__)
@memo_bp.route('/api/memos', methods=['GET'])
@login_required
def get_memos():
limit = request.args.get('limit', 20, type=int)
offset = request.args.get('offset', 0, type=int)
group = request.args.get('group', 'all')
query = request.args.get('query', '')
date = request.args.get('date', '')
conn = get_db()
c = conn.cursor()
where_clauses = []
params = []
# 1. 그룹/태그 필터링
if group == GROUP_DONE:
where_clauses.append("status = 'done'")
elif group.startswith('tag:'):
tag_name = group.split(':')[-1]
where_clauses.append("status != 'done'")
where_clauses.append("id IN (SELECT memo_id FROM tags WHERE name = ?)")
params.append(tag_name)
elif group != 'all':
where_clauses.append("status != 'done'")
where_clauses.append("group_name = ?")
params.append(group)
else:
where_clauses.append("status != 'done'")
# 2. 검색어 필터링
if query:
where_clauses.append("(title LIKE ? OR content LIKE ?)")
params.append(f"%{query}%")
params.append(f"%{query}%")
# 3. 날짜 필터링 (캘린더 선택)
if date:
where_clauses.append("created_at LIKE ?")
params.append(f"{date}%")
# 4. 초기 로드 시 최근 5일 강조 (필터가 없는 경우에만 적용)
if offset == 0 and group == 'all' and not query and not date:
start_date = (datetime.datetime.now() - datetime.timedelta(days=5)).isoformat()
where_clauses.append("(updated_at >= ? OR is_pinned = 1)")
params.append(start_date)
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
query_sql = f"SELECT * FROM memos WHERE {where_sql} ORDER BY is_pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
c.execute(query_sql, params + [limit, offset])
memo_rows = c.fetchall()
if not memo_rows:
conn.close()
return jsonify([])
memos = [dict(r) for r in memo_rows]
memo_ids = [m['id'] for m in memos]
placeholders = ','.join(['?'] * len(memo_ids))
# --- 🚀 Bulk Fetch: N+1 문제 해결 ---
# 태그 한꺼번에 가져오기
c.execute(f'SELECT memo_id, name, source FROM tags WHERE memo_id IN ({placeholders})', memo_ids)
tags_rows = c.fetchall()
tags_map = {}
for t in tags_rows:
tags_map.setdefault(t['memo_id'], []).append(dict(t))
# 첨부파일 한꺼번에 가져오기
c.execute(f'SELECT id, memo_id, filename, original_name, file_type, size FROM attachments WHERE memo_id IN ({placeholders})', memo_ids)
attachments_rows = c.fetchall()
attachments_map = {}
for a in attachments_rows:
attachments_map.setdefault(a['memo_id'], []).append(dict(a))
# 백링크 한꺼번에 가져오기
c.execute(f'''
SELECT ml.target_id, m.id as source_id, m.title
FROM memo_links ml
JOIN memos m ON ml.source_id = m.id
WHERE ml.target_id IN ({placeholders})
''', memo_ids)
backlinks_rows = c.fetchall()
backlinks_map = {}
for l in backlinks_rows:
backlinks_map.setdefault(l['target_id'], []).append(dict(l))
# 전방 링크(Forward Links) 한꺼번에 가져오기
c.execute(f'''
SELECT ml.source_id, m.id as target_id, m.title
FROM memo_links ml
JOIN memos m ON ml.target_id = m.id
WHERE ml.source_id IN ({placeholders})
''', memo_ids)
links_rows = c.fetchall()
links_map = {}
for l in links_rows:
links_map.setdefault(l['source_id'], []).append(dict(l))
# 데이터 가공 및 병합
for m in memos:
m['tags'] = tags_map.get(m['id'], [])
m['attachments'] = attachments_map.get(m['id'], [])
m['backlinks'] = backlinks_map.get(m['id'], [])
m['links'] = links_map.get(m['id'], [])
conn.close()
return jsonify(memos)
@memo_bp.route('/api/stats/heatmap', methods=['GET'])
@login_required
def get_heatmap_stats():
days = request.args.get('days', 365, type=int)
conn = get_db()
c = conn.cursor()
# 파라미터로 받은 일수만큼 데이터 조회
start_date = (datetime.datetime.now() - datetime.timedelta(days=days)).isoformat()
c.execute('''
SELECT strftime('%Y-%m-%d', created_at) as date, COUNT(*) as count
FROM memos
WHERE created_at >= ?
GROUP BY date
''', (start_date,))
stats = c.fetchall()
conn.close()
return jsonify([dict(s) for s in stats])
@memo_bp.route('/api/memos', methods=['POST'])
@login_required
def create_memo():
data = request.json
title = data.get('title', '').strip()
content = data.get('content', '').strip()
color = data.get('color', '#2c3e50')
is_pinned = 1 if data.get('is_pinned') else 0
status = data.get('status', 'active').strip()
group_name = data.get('group_name', GROUP_DEFAULT).strip()
user_tags = data.get('tags', [])
is_encrypted = 1 if data.get('is_encrypted') else 0
password = data.get('password', '').strip()
if is_encrypted and password:
content = encrypt_content(content, password)
elif is_encrypted and not password:
return jsonify({'error': 'Password required for encryption'}), 400
now = datetime.datetime.now().isoformat()
if not title and not content:
return jsonify({'error': 'Title or content required'}), 400
conn = get_db()
c = conn.cursor()
try:
c.execute('''
INSERT INTO memos (title, content, color, is_pinned, status, group_name, is_encrypted, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (title, content, color, is_pinned, status, group_name, is_encrypted, now, now))
memo_id = c.lastrowid
for tag in user_tags:
if tag.strip():
c.execute('INSERT INTO tags (memo_id, name, source) VALUES (?, ?, ?)', (memo_id, tag.strip(), 'user'))
links = extract_links(content)
for target_id in links:
c.execute('INSERT INTO memo_links (source_id, target_id) VALUES (?, ?)', (memo_id, target_id))
attachment_filenames = data.get('attachment_filenames', [])
for fname in set(attachment_filenames):
c.execute('UPDATE attachments SET memo_id = ? WHERE filename = ?', (memo_id, fname))
conn.commit()
current_app.logger.info(f"Memo Created: ID {memo_id}, Title: '{title}', Encrypted: {is_encrypted}")
return jsonify({'id': memo_id, 'message': 'Memo created'}), 201
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@memo_bp.route('/api/memos/<int:memo_id>', methods=['PUT'])
@login_required
def update_memo(memo_id):
data = request.json
title = data.get('title')
content = data.get('content')
color = data.get('color')
is_pinned = data.get('is_pinned')
status = data.get('status')
group_name = data.get('group_name')
user_tags = data.get('tags')
is_encrypted = data.get('is_encrypted')
password = data.get('password', '').strip()
now = datetime.datetime.now().isoformat()
conn = get_db()
c = conn.cursor()
# 보안: 암호화된 메모 수정 시 비밀번호 검증
c.execute('SELECT content, is_encrypted FROM memos WHERE id = ?', (memo_id,))
memo = c.fetchone()
if memo and memo['is_encrypted']:
# 암호화된 메모지만 '암호화 해제(is_encrypted=0)' 요청이 온 경우는
# 비밀번호 없이도 수정을 시도할 수 있어야 할까? (아니오, 인증이 필요함)
if not password:
conn.close()
return jsonify({'error': _t('msg_encrypted_locked')}), 403
# 비밀번호가 맞는지 검증 (복호화 시도)
if decrypt_content(memo['content'], password) is None:
conn.close()
return jsonify({'error': _t('msg_auth_failed')}), 403
try:
updates = ['updated_at = ?']
params = [now]
# 암호화 처리 로직: 암호화가 활성화된 경우(또는 새로 설정하는 경우) 본문 암호화
final_content = content.strip() if content is not None else None
if (is_encrypted or (is_encrypted is None and memo['is_encrypted'])) and password:
if final_content is not None:
final_content = encrypt_content(final_content, password)
if title is not None:
updates.append('title = ?'); params.append(title.strip())
if final_content is not None:
updates.append('content = ?'); params.append(final_content)
if color is not None:
updates.append('color = ?'); params.append(color)
if is_pinned is not None:
updates.append('is_pinned = ?'); params.append(1 if is_pinned else 0)
if status is not None:
updates.append('status = ?'); params.append(status.strip())
if group_name is not None:
updates.append('group_name = ?'); params.append(group_name.strip() or GROUP_DEFAULT)
if is_encrypted is not None:
updates.append('is_encrypted = ?'); params.append(1 if is_encrypted else 0)
params.append(memo_id)
c.execute(f"UPDATE memos SET {', '.join(updates)} WHERE id = ?", params)
if user_tags is not None:
c.execute("DELETE FROM tags WHERE memo_id = ? AND source = 'user'", (memo_id,))
for tag in user_tags:
if tag.strip():
c.execute('INSERT INTO tags (memo_id, name, source) VALUES (?, ?, ?)', (memo_id, tag.strip(), 'user'))
if content is not None:
c.execute("DELETE FROM memo_links WHERE source_id = ?", (memo_id,))
links = extract_links(content)
for target_id in links:
c.execute('INSERT INTO memo_links (source_id, target_id) VALUES (?, ?)', (memo_id, target_id))
# [Bug Fix] 첨부파일 링크는 본문 수정 여부와 상관없이 항상 갱신
attachment_filenames = data.get('attachment_filenames')
if attachment_filenames is not None:
c.execute('UPDATE attachments SET memo_id = NULL WHERE memo_id = ?', (memo_id,))
for fname in set(attachment_filenames):
c.execute('UPDATE attachments SET memo_id = ? WHERE filename = ?', (memo_id, fname))
if is_encrypted is not None:
if is_encrypted and password and content:
enc_content = encrypt_content(content, password)
c.execute("UPDATE memos SET is_encrypted = 1, content = ? WHERE id = ?", (enc_content, memo_id))
elif is_encrypted == 0:
c.execute("UPDATE memos SET is_encrypted = 0 WHERE id = ?", (memo_id,))
conn.commit()
current_app.logger.info(f"Memo Updated: ID {memo_id}, Fields: {list(data.keys())}")
return jsonify({'message': 'Updated'})
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@memo_bp.route('/api/memos/<int:memo_id>', methods=['DELETE'])
@login_required
def delete_memo(memo_id):
import os
from flask import current_app
conn = get_db()
c = conn.cursor()
# 1. 암호화 여부 확인 및 파일 목록 가져오기
c.execute('SELECT is_encrypted FROM memos WHERE id = ?', (memo_id,))
memo = c.fetchone()
if not memo:
conn.close()
return jsonify({'error': 'Memo not found'}), 404
if memo['is_encrypted']:
conn.close()
return jsonify({'error': _t('msg_encrypted_locked')}), 403
# 2. 물리적 파일 삭제 준비
c.execute('SELECT filename FROM attachments WHERE memo_id = ?', (memo_id,))
files = c.fetchall()
upload_folder = current_app.config['UPLOAD_FOLDER']
try:
# 3. 물리 파일 삭제 루프
for f in files:
filepath = os.path.join(upload_folder, f['filename'])
if os.path.exists(filepath):
os.remove(filepath)
current_app.logger.info(f"Physical file deleted on memo removal: {f['filename']}")
# 4. 메모 삭제 (외래 키 제약 조건에 의해 tags 등은 자동 삭제되거나 처리됨)
# Note: attachments 테이블의 memo_id는 SET NULL 설정이므로 수동으로 레코드도 삭제해줍니다.
c.execute('DELETE FROM attachments WHERE memo_id = ?', (memo_id,))
c.execute('DELETE FROM memos WHERE id = ?', (memo_id,))
conn.commit()
current_app.logger.info(f"Memo and its {len(files)} files deleted: ID {memo_id}")
return jsonify({'message': 'Deleted memo and all associated files'})
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@memo_bp.route('/api/memos/<int:memo_id>/decrypt', methods=['POST'])
@login_required
def decrypt_memo_route(memo_id):
data = request.json
password = data.get('password')
if not password: return jsonify({'error': 'Password required'}), 400
conn = get_db(); c = conn.cursor()
c.execute('SELECT content, is_encrypted FROM memos WHERE id = ?', (memo_id,))
memo = c.fetchone(); conn.close()
if not memo: return jsonify({'error': 'Memo not found'}), 404
if not memo['is_encrypted']: return jsonify({'content': memo['content']})
decrypted = decrypt_content(memo['content'], password)
if decrypted is None:
current_app.logger.warning(f"Decryption FAILED: ID {memo_id}")
return jsonify({'error': 'Invalid password'}), 403
current_app.logger.info(f"Decryption SUCCESS: ID {memo_id}")
return jsonify({'content': decrypted})
+58
View File
@@ -0,0 +1,58 @@
import os
import json
from flask import Blueprint, request, jsonify, current_app # type: ignore
from ..auth import login_required
settings_bp = Blueprint('settings', __name__)
CONFIG_PATH = os.path.join(os.getcwd(), 'config.json')
# 기본 테마 및 시스템 설정
DEFAULT_SETTINGS = {
"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",
"enable_ai": True,
"lang": "ko"
}
@settings_bp.route('/api/settings', methods=['GET'])
@login_required
def get_settings():
if not os.path.exists(CONFIG_PATH):
return jsonify(DEFAULT_SETTINGS)
try:
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
data = json.load(f)
# 기본값과 병합하여 신규 필드 등 누락 방지
full_data = {**DEFAULT_SETTINGS, **data}
return jsonify(full_data)
except Exception as e:
return jsonify(DEFAULT_SETTINGS)
@settings_bp.route('/api/settings', methods=['POST'])
@login_required
def save_settings():
data = request.json
if not data:
return jsonify({'error': 'No data provided'}), 400
try:
# 기존 데이터 로드 후 병합
current_data = {}
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
current_data = json.load(f)
updated_data = {**current_data, **data}
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(updated_data, f, indent=4, ensure_ascii=False)
current_app.logger.info(f"System Settings Updated: {list(data.keys())}")
return jsonify({'message': 'Settings saved successfully'})
except Exception as e:
return jsonify({'error': str(e)}), 500
+58
View File
@@ -0,0 +1,58 @@
import os
import base64
from cryptography.fernet import Fernet # type: ignore
from cryptography.hazmat.primitives import hashes # type: ignore
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC # type: ignore
def derive_key(password: str):
"""
.env에 설정된 ENCRYPTION_SEED를 솔트로 사용하여 사용자 비밀번호로부터 암호화 키를 파생합니다.
"""
seed = os.getenv('ENCRYPTION_SEED', 'default_secret_seed_123').encode()
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=seed,
iterations=100000,
)
key = base64.urlsafe_b64encode(kdf.derive(password.encode()))
return key
def encrypt_content(content: str, password: str):
"""지정된 비밀번호로 내용을 암호화합니다."""
key = derive_key(password)
f = Fernet(key)
encrypted_data = f.encrypt(content.encode()).decode()
return encrypted_data
def decrypt_content(encrypted_data: str, password: str):
"""지정된 비밀번호로 암호화된 내용을 복호화합니다."""
try:
key = derive_key(password)
f = Fernet(key)
decrypted_data = f.decrypt(encrypted_data.encode()).decode()
return decrypted_data
except Exception:
# 비밀번호가 틀리거나 데이터가 손상된 경우
return None
def get_master_key():
"""파일 암호화에 사용될 시스템 마스터 키를 생성합니다."""
seed = os.getenv('ENCRYPTION_SEED', 'master_default_seed_777')
return derive_key(seed)
def encrypt_file(data: bytes):
"""데이터를 시스템 마스터 키로 암호화합니다."""
key = get_master_key()
f = Fernet(key)
return f.encrypt(data)
def decrypt_file(data: bytes):
"""데이터를 시스템 마스터 키로 복호화합니다."""
try:
key = get_master_key()
f = Fernet(key)
return f.decrypt(data)
except Exception:
# 복호화 실패 (암호화되지 않은 파일이거나 키가 다름)
return None
+33
View File
@@ -0,0 +1,33 @@
import re
from ..constants import GROUP_DEFAULT
def parse_metadata(text):
"""
텍스트에서 ##그룹명 과 #태그 추출 유틸리티.
"""
group_name = GROUP_DEFAULT
tags = []
if not text:
return group_name, tags
group_match = re.search(r'##(\S+)', text)
if group_match:
group_name = group_match.group(1)
tag_matches = re.finditer(r'(?<!#)#(\S+)', text)
for match in tag_matches:
tags.append(match.group(1))
return group_name, list(set(tags))
def extract_links(text):
"""
텍스트에서 [[#ID]] 형태의 내부 링크를 찾아 ID 목록(정수)을 반환합니다.
"""
if not text:
return []
# [[#123]] 패턴 매칭
links = re.findall(r'\[\[#(\d+)\]\]', text)
return list(set([int(link_id) for link_id in links]))
+41
View File
@@ -0,0 +1,41 @@
import json
import os
from flask import current_app # type: ignore
class I18n:
_locales = {}
_default_lang = 'en'
@classmethod
def load_locales(cls):
locales_dir = os.path.join(current_app.static_folder, 'locales')
for filename in os.listdir(locales_dir):
if filename.endswith('.json'):
lang = filename.split('.')[0]
with open(os.path.join(locales_dir, filename), 'r', encoding='utf-8') as f:
cls._locales[lang] = json.load(f)
@classmethod
def t(cls, key, lang=None):
if not lang:
# 기본적으로 config에서 언어를 가져오거나 'en' 사용
lang = current_app.config.get('lang', cls._default_lang)
if not cls._locales:
cls.load_locales()
data = cls._locales.get(lang, cls._locales.get(cls._default_lang, {}))
# 중첩 키 지원 (예: "groups.done")
parts = key.split('.')
for p in parts:
if isinstance(data, dict):
data = data.get(p)
else:
return key
return data or key
# 숏컷 함수
def _t(key, lang=None):
return I18n.t(key, lang)
+17
View File
@@ -0,0 +1,17 @@
import platform
from app import create_app
app = create_app()
if __name__ == "__main__":
# OS 환경에 따른 설정 분기
is_windows = platform.system() == "Windows"
# Windows(개발/디버그): 5050 포트, Linux(운영): 5093 포트
port = 5050 if is_windows else 5093
debug_mode = True if is_windows else False
print(f"📡 {'Windows' if is_windows else 'Linux'} 환경 감지 - Port: {port}, Debug: {debug_mode}")
# 향후 Linux 서버 구축시 gunicorn / uwsgi 로 구동 권장
app.run(host="0.0.0.0", port=port, debug=debug_mode)
+9
View File
@@ -0,0 +1,9 @@
{
"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",
"enable_ai": false,
"lang": "en"
}
+30
View File
@@ -0,0 +1,30 @@
# 🧠 뇌사료 (Brain Dogfood) 프로젝트 문서화 (v5.0+)
> **"지식은 기록될 때 힘을 얻고, 연결될 때 생명을 얻는다."**
본 프로젝트는 개인용 지식창고 및 메모 서버로, **보안**, **지능형 연결(Nebula)**, 그리고 **기록의 습관(Heatmap)**을 핵심 가치로 삼습니다.
## 🚀 프로젝트 개요
- **이름**: 뇌사료 (Brain Dogfood)
- **핵심 가치**:
- **지식 네뷸라 (Knowledge Nebula)**: D3.js 기반의 유기적인 지식 그래프 시각화.
- **지식 성장 히트맵 (Growth Heatmap)**: 꾸준한 기록을 시각화하는 활동 대시보드.
- **이중 보안**: 메모별 개별 암호화 및 미디어 보안 실드.
- **AI 구조화**: Gemini 2.0 Flash 기반 자동 요약 및 지능형 태깅.
## ✨ What's New in v5.0
- **Heatmap**: 최근 1년/6개월/3개월/1개월 활동량 지표 추가.
- **Performance**: 대량 데이터 로딩 최적화(Bulk Fetch) 및 무한 스크롤 도입.
- **Editor**: 중요 지식 강조를 위한 글자 색상(Color Syntax) 기능 통합.
## 📂 문서 인덱스
1. [**사용자 매뉴얼 (User Manual)**](user_manual.md): **[최초 사용자 필독]** 사용법 및 연결 문법
2. [**시스템 아키텍처 (Architecture)**](architecture.md): 폴더 구조 및 모듈형 설계
3. [**데이터베이스 및 API 명세 (API Reference)**](api_reference.md): DB 스키마 및 확장된 API 명세
4. [**핵심 기능 가이드 (Features)**](features.md): 히트맵 알고리즘 및 AI 인사이트 메커니즘
5. [**로직 흐름도 (Logic Flow)**](logic_flow.md): 앱 라이프사이클 및 보안 상태 전이 규칙
6. [**소스 매핑 가이드 (Source Mapping)**](source_mapping.md): 호출 관계 및 Ops 도구 매핑
7. [**단축키 가이드 (Shortcuts Guide)**](shortcuts.md): **[업무 효율 극대화]** 탐색 및 편집 단축키 총정리
---
*Last Updated: 2026-04-15*
+44
View File
@@ -0,0 +1,44 @@
# 📡 데이터베이스 및 API 명세서 (v13.5)
본 문서는 `뇌사료` 프로젝트의 데이터 저장 구조(Schema)와 모든 외부 통신 인터페이스(API)를 상세히 기술합니다.
## 🗄️ 1. 데이터베이스 스키마 (DB Schema)
### 1.1 `memos` 테이블
메모의 핵심 데이터를 저장합니다.
| 컬럼명 | 타입 | 기본값 | 설명 |
| :--- | :--- | :--- | :--- |
| `id` | INTEGER | PRIMARY KEY | 자동 증가 고유 아이디 |
| `title` | TEXT | - | 메모 제목 |
| `content` | TEXT | - | 메모 본문 (암호화 시 바이너리 텍스트) |
| `is_encrypted` | BOOLEAN | 0 | 암호화 여부 |
### 1.2 `memo_links` 테이블 (v7.0 추가)
메모 간의 `[[#ID]]` 링크 및 시각화 인력을 관리합니다.
| 컬럼명 | 타입 | 설명 |
| :--- | :--- | :--- |
| `source_id` | INTEGER | 링크를 건 메모 ID |
| `target_id` | INTEGER | 링크 대상 메모 ID |
---
## 🌐 2. API 엔드포인트 전수 명세
### 2.1 Memos & Analysis
| Method | URL | Description |
| :--- | :--- | :--- |
| `GET` | `/api/memos` | 전체 메모 목록, 태그, 첨부파일, **백링크** 정보 통합 조회 |
| `POST` | `/api/memos/<id>/decrypt` | 비밀번호 검증 및 본문 일시 복호화 |
| `GET` | `/api/stats/heatmap` | 최근 N일간의 일자별 메모 작성 수(통계) 조회 (`days` 파라미터 지원) |
### 2.2 Assets (제한적 접근)
| Method | URL | Security Policy | Description |
| :--- | :--- | :--- | :--- |
| `GET` | `/api/download/<filename>` | **세션 필수(로그인 상호작용)** | 이미지/파일 다운로드. 이미지인 경우 `inline` 처리 및 암호화 메모 관련 파일은 로그인 미달 시 403 차단. |
| `POST` | `/api/upload` | `login_required` | 파일 업로드 및 서버 측 마스터 키 암호화 저장. |
### 2.3 Settings & Ops (v11.0 추가)
| Method | URL | Description |
| :--- | :--- | :--- |
| `GET` | `/api/settings` | 서버 사이드 테마 및 전역 설정 조회 |
| `POST` | `/api/settings` | UI 테마 설정을 서버에 영구 기록 |
+38
View File
@@ -0,0 +1,38 @@
# 🏢 시스템 아키텍처 및 폴더 구조 (v5.0+)
본 문서는 `뇌사료` 프로젝트의 물리적 파일 구조와 논리적 설계 아키텍처를 상세히 기술합니다.
## 📁 1. 폴더 구조 (Folder Structure)
| 경로 | 역할 | 상세 설명 |
| :--- | :--- | :--- |
| `/app` | **Backend Core** | Flask 애플리케이션의 핵심 로직 및 라우트 |
| `/app/routes` | **Modular Routes** | 기능별로 분리된 API 엔드포인트 패키지 |
| `/data` | **Database Box** | SQLite3 DB 파일 (`memos.db`) 저장 위치 |
| `/docs` | **Documentation** | 시스템 기술 문서 및 가이드 |
| `/logs` | **Log Box** | 시스템 작동 및 접근 로그 (`app.log`) |
| `/static` | **Static Assets** | CSS, 이미지, 파비코 및 프론트엔드 JS |
| `/static/js/components` | **UI Components** | D3.js 시각화 모듈 및 UI 핵심 로직 |
| `/templates` | **HTML Templates** | Jinja2 기반 레이아웃 및 페이지 |
| `deploy.py` | **Ops Tool** | 수술적 정밀 배포 도구 (Surgical Deployment) |
| `backup.py` | **Disaster Recovery** | 핵심 데이터(DB, .env, 첨부파일) 증분 백업 도구 |
---
## 🏗️ 2. 설계 아키텍처 (Design Architecture)
### 2.1 Backend: Blueprint-based Modular Flask
- **패키지 구조**: `app/__init__.py`에서 중앙 집중식으로 앱을 생성하고, `routes/` 아래의 각 기능을 Blueprint로 등록합니다.
- **보안 실드 (Security Shield)**: `before_request` 단계에서 비정상적인 트래픽 및 파라미터를 필터링하는 로깅 시스템이 선제적으로 작동합니다.
- **성능 최적화 (Bulk Fetch)**: 다량의 메모리 조회 시 발생하는 N+1 문제를 방지하기 위해 태그, 첨부파일, 백링크 정보를 한꺼번에 Fetch하는 벌크 조회 로직이 적용되었습니다.
### 2.2 Frontend: Modular Component Architecture
- **지식 네뷸라 (Knowledge Nebula)**: D3.js의 물리 시뮬레이션 엔진을 도입하여 유기적인 성단 구조를 시각화합니다.
- **컴포넌트 중심 설계**: `HeatmapManager.js` (활동 시각화), `CalendarManager.js` (달력), `Visualizer.js` (그래프), `DrawerManager.js` (탐색기) 등으로 독립된 모듈 구조를 채택하여 유지보수성을 극대화했습니다.
- **레이아웃 혁명**: **무한 스크롤(Infinite Scroll)** 페이징 기법을 도입하여 수천 개의 지식 파편도 성능 저하 없이 탐색할 수 있습니다.
- **State Management**: `AppService.js`를 중앙 상태 관리 엔진으로 활용하여 데이터 요청과 UI 업데이트의 정합성을 유지합니다.
### 2.3 Ops & Reliability
- **Merged Configuration**: 개발/운영 환경의 환경변수를 한곳에서 관리하며, 배포 시 `.env` 파일을 통해 보안 설정이 주입됩니다.
- **Surgical Cleanup**: 배포 시 운영 데이터(DB, Uploads)는 보존하고 코드 영역만 정밀하게 교체하는 수술적 배포 방식을 채택했습니다.
- **Disaster Recovery**: `backup.py`를 통해 서버 침해나 시스템 붕괴 시에도 3대 핵심 자산(.env, DB, Uploads)만으로 즉시 복구가 가능한 구조를 갖췄습니다.
+48
View File
@@ -0,0 +1,48 @@
# 💎 핵심 기능 가이드 (v13.3)
본 문서는 `뇌사료` 프로젝트를 상징하는 핵심 기능들인 **지식 시각화**, **암호화**, **AI 분석**에 대한 상세 명세를 담고 있습니다.
## 🌌 1. 지식 네뷸라 (Knowledge Nebula)
D3.js v7 물리 시뮬레이션 엔진을 통해 파편화된 메모들을 유기적인 우주 성단 구조로 시각화합니다.
### 1.1 시각화 아키텍처
- **엔진**: D3.js Force Simulation
- **성단(Constellation) 로직**:
- **그룹 인력**: 같은 그룹에 속한 메모들은 서로를 끌어당겨 하나의 별무리를 형성합니다.
- **의미론적 연결**: 공통 태그를 공유하는 노드들 사이에 보이지 않는 인력을 설정하여 맥락이 유사한 지식들이 근접하게 배치됩니다.
- **인터랙션**: 노드 클릭 시 상세 정보 모달이 출력되며, 마우스 호버 시 연결된 지식망이 강조(Highlight)됩니다.
## 🔒 2. 이중 보안 암호화 시스템 (Dual-Layer Security)
### 2.1 메모 및 파일 보안
- **개별 암호화**: 메모마다 고유한 비밀번호를 사용하여 `Fernet (AES-128 CBC/HMAC)` 방식으로 본문을 암호화합니다.
- **미디어 실드 (v10.1)**: 모든 첨부파일은 서버 마스터 키로 암호화되어 저장됩니다. 암호화된 메모의 이미지는 **로그인된 세션**에서만 정밀하게 렌더링을 허용하여 데이터 유출을 원천 차단합니다.
## 🧠 3. Gemini AI 기반 지식 구조화 (AI Insight)
### 3.1 자동 추출 및 요약
- **학습된 페르소나**: 최신 Gemini 2.0 Flash 모델이 메모의 맥락을 분석하여 핵심 요약과 태그를 생성합니다. (.env에서 모델 식별자를 언제든 변경할 수 있습니다.)
- **지능형 연동**: AI가 생성한 태그는 지식 네뷸라 엔진의 인력 설정에 반영되어, 사용자가 명시적으로 연결하지 않아도 관련 지식끼리 우주 상에서 가까이 부유하게 됩니다.
## 🔗 4. 내부 링크 및 백링크 시스템
### 4.1 연결 문법 (`[[#ID]]`)
- **자동 링크**: 본문에 `[[#12]]`와 같이 입력하면 뷰어에서 클릭 가능한 링크로 변환되며, 지식 맵 상에서 두 노드 사이에 **강력한 실선**이 형성됩니다.
- **역방향 추적 (Backlinks)**: 특정 메모 카드 하단에 해당 메모를 인용 중인 다른 메모의 목록이 노출되어, 지식의 흐름을 양방향으로 추적할 수 있습니다.
27:
32: ## 🌡️ 5. 지식 성장 히트맵 (Intellectual Growth Heatmap) - v14.0
33:
34: ### 5.1 활동 시각화
35: - **기록 습관 형성**: 최근 365일간의 활동량을 GitHub 스타일의 그리드로 시각화하여 지식 축적의 꾸준함을 독려합니다.
36: - **동적 범위 필터링**: 사용자의 필요에 따라 **1개월 / 3개월 / 6개월 / 1년** 단위를 자유롭게 선택하여 볼 수 있습니다.
37: - **상태 보존**: 선택한 보기 설정은 `localStorage`에 저장되어 재접속 시에도 유지됩니다.
38:
39: ### 5.2 지능형 히트맵 알고리즘
40: - **단계별 농도**: 해당 일의 메모 작성 수에 따라 5단계(`lvl-0`~`lvl-4`)의 색상 농도가 적용됩니다.
41: - **프리미엄 그라데이션**: 뇌사료 특유의 Cyan(시안)에서 Purple(보라)로 이어지는 네온 그라데이션 테마를 따릅니다.
42:
43: ## 🎨 6. 확장된 에디터 스타일링 (Enhanced Editor)
44:
45: ### 6.1 컬러 텍스트 (Color Syntax)
46: - **시각적 강조**: Toast UI Editor의 컬러 신택스 플러그인을 통합하여, 본문 중 중요한 지식 키워드를 다양한 색상으로 강조할 수 있습니다.
47: - **지각적 설계**: 다크 모드 환경에서도 가독성이 뛰어난 색상 팔레트를 우선적으로 제공합니다.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

+55
View File
@@ -0,0 +1,55 @@
# ⚙️ 로직 흐름 및 비즈니스 규칙 (v5.0+)
본 문서는 `뇌사료` 프로젝트 내부에서 작동하는 특수 로직들과 데이터 처리 규칙을 상세히 설명합니다.
## 🔒 1. 암호화 전이 및 보안 로직
본 시스템은 메모의 보안 상태 변화에 따른 정교한 전이 로직을 가지고 있습니다.
### 1.1 암호화 해제 시 상태 전이
- **명시적 해제**: 사용자가 비밀번호를 입력하여 암호화를 해제하고 저장하는 경우, 해당 메모는 **평문(Plaintext)** 상태로 전환됩니다. 이는 사용자의 명시적 의사 결정으로 간주합니다.
- **수정 시 유지**: 암호화된 상태에서 내용만 수정할 경우, 기존 비밀번호를 사용하여 백엔드에서 다시 암호화(Re-encrypt) 과정을 거쳐 저장됩니다.
### 1.2 첨부파일 접근 제어 (403 해결)
- **인증 연동**: `/api/download` 라우트는 단순히 파일 존재 여부만 체크하지 않고, 사용자의 **세션 로그인 여부**를 확인합니다.
- **인라인 표시**: 이미지 파일의 경우 `Content-Disposition: inline` 헤더를 강제하여 브라우저 마크다운 본문 내에서 선명하게 렌더링되도록 지원합니다.
---
## 🌌 2. 지식 네뷸라 성단(Constellation) 로직
D3.js 엔진은 데이터 간의 명시적 링크 외에도 의미론적 연결을 자동으로 시뮬레이션합니다.
### 2.1 자동 연결 규칙 (Semantic Linking)
1. **명시적 링크**: `[[#ID]]` 패턴으로 작성된 내부 링크 (실선 표시).
2. **동일 그룹**: 같은 그룹에 속한 메모들끼리 부유하며 성단을 형성 (은은한 연결선).
3. **공통 태그**: 같은 태그를 공유하는 메모들 사이에 인력이 작용하여 근접 배치.
### 2.2 이미지 경로 보정 (Path Resolution)
- **후처리 로직**: 마크다운 본문의 `img src="photo.png"`와 같은 상대 경로를 `fixImagePaths` 유틸리티가 감지하여 `/api/download/photo.png`로 자동 보정합니다.
---
---
## 🔄 3. 애플리케이션 라이프사이클 및 데이터 흐름
### 3.1 초기화 및 갱신 사이클 (App Cycle)
1. **DOM 로드**: `DOMContentLoaded` 발생 시 모든 매니저(`Editor`, `Heatmap`, `Visualizer` 공히)가 `init()`을 거칩니다.
2. **데이터 호출**: `AppService.refreshData()`가 실행되어 첫 페이지 메모를 가져옵니다.
3. **병렬 동기화**: 메모 데이터 수신 시 `HeatmapManager.refresh()`가 별도 API를 호출하여 최근 1년(또는 선택 기간)의 통계를 가져와 렌더링합니다.
### 3.2 검색 및 실시간 필터링 (Debounce)
- **지연 처리**: 검색창 입력 시 300ms의 `searchTimer`가 작동하여 불필요한 API 요청을 최소화합니다.
- **상태 통합**: 검색어는 그룹/날짜 필터와 결합되어 중앙 상태(`AppService.state`)에서 관리됩니다.
### 3.3 무한 스크롤 및 페이징 (Infinite Scroll)
- **오프셋 제어**: `this.state.offset`을 통해 현재 로드된 개수를 추적하며, 하단 도달 시 `limit` 단위로 다음 데이터를 추가 로드합니다.
---
## 🚀 4. 정밀 배포 및 재난 복구 로직
### 4.1 수술적 정리 (Surgical Cleanup)
- **보호 대상**: `.env`, `data/`, `static/uploads/`, `memos.db` 등 사용자의 생성 데이터는 삭제 목록에서 철저히 제외됩니다.
- **정밀 삭제**: 부모 폴더(예: `static`)를 지우지 않고 그 내부의 코드(JS, CSS)만 선별 삭제하여, 하위의 보호 폴더(`uploads`) 유실을 방지합니다.
### 4.2 핵심 자산 백업 (Disaster Recovery)
- **3대 요소**: DB, 첨부파일, 환경설정(.env)을 하나의 압축파일(`tar.gz`)로 통합합니다. 이 세 가지만 있으면 서버가 완전히 붕괴되어도 다른 환경에서 즉시 복호화 및 서비스 재개가 가능합니다.
+32
View File
@@ -0,0 +1,32 @@
# ⌨️ 단축키 가이드 (Keyboard Shortcuts)
'뇌사료'를 더욱 빠르고 효율적으로 활용하기 위한 단축키 일람입니다.
**Ctrl + Shift**를 주력으로 하며, 전설적인 **Quake 스타일**의 새 메모 단축키를 추가 지원합니다.
## 🚀 전역 단축키 (Global Navigation)
어느 화면에서나 즉시 실행됩니다.
- **`Alt + `** (Backtick): ⚡ **Quake 스타일 새 메모** (가장 빠른 작성기 호출)
- **`Ctrl + Shift + N`**: 📝 **새 메모 작성**
- **`Ctrl + Shift + G`**: 🕸️ **지식 네뷸라** (시각화 모달 열기)
- **`Ctrl + Shift + E`**: 🔍 **지식 탐색기** (사이드바 드로어 열기)
- **`Ctrl + Shift + C`**: 📅 **캘린더 토글** (사이드바 미니 달력)
- **`Ctrl + Shift + Q`** 또는 **`ESC`**: 🚪 **닫기**
---
## ✍️ 메모 편집 (Editor Mode)
- **`Ctrl + Enter`** 또는 **`Ctrl + S`**: 💾 **저장 및 게시**
- **`/` (Slash)**: 🪄 **슬래시 명령**
- **`Shift + ESC`**: 🗑️ **작성 취소**
---
## 🖱️ 마우스 인터렉션
- **Alt + 클릭**: 🪄 **즉시 수정**
- **클릭**: 상세 보기
---
> [!TIP]
> - **`Alt + `** 단축키는 게임 'Quake'의 콘솔창 호출 방식에서 유래한 것으로, 영감이 떠오른 순간 가장 빠르게 기록을 시작할 수 있는 방법입니다.
+42
View File
@@ -0,0 +1,42 @@
# 🔗 소스 매핑 및 호출 관계 (v13.4)
본 문서는 프론트엔드 컴포넌트와 백엔드 API, 그리고 내부 함수 간의 호출 관계와 인터페이스를 기술합니다.
## 📱 1. 프론트엔드 모듈간 관계
| 모듈 (Component) | 기능 설명 | 주요 호출 (Callee) |
| :--- | :--- | :--- |
| `app.js` | **Orchestrator** | `Visualizer`, `DrawerManager`, `ModalManager`, `API` |
| `Visualizer.js` | **Nebula Engine** | `D3.js`, `ModalManager.open()`, `AppService` |
| `DrawerManager.js` | **Explorer** | `AppService.filterMemos()`, `ModalManager` |
| `ModalManager.js` | **Viewer** | `utils.parseInternalLinks()`, `utils.fixImagePaths()` |
| `ComposerManager.js` | **Editor** | `API.saveMemo()`, `AttachmentBox` |
---
## ⚙️ 2. 백엔드 핵심 함수 매핑
### 2.1 보안 및 유틸리티
| 함수명 | 모듈 | 역할 | 호출자 |
| :--- | :--- | :--- | :--- |
| `decrypt_file` | `app/security.py` | 첨부파일 물리 복호화 | `file.py:download_file` |
| `extract_links` | `app/utils.py` | `[[#ID]]` 패턴 추출 | `memo.py:create/update` |
### 2.2 운영 도구 (Ops)
| 파일명 | 역할 | 주요 로직 |
| :--- | :--- | :--- |
| `deploy.py` | **정밀 배포** | SSH/SFTP 기반 Surgical Cleanup & Upload |
| `backup.py` | **백업** | 핵심 자산(.env, DB, Uploads) Tarball 생성 |
---
## 🌐 3. 클라이언트-서버 통신 파라미터 (API Flow)
### 3.1 `GET /api/download/<filename>` (보안 하향 링크)
- **Caller**: `ModalManager.js` (Inline Images) or `AttachmentBox.js`
- **Security**: `session['logged_in']` 확인 -> `is_encrypted` 상태에 따른 접근 제어.
- **Header**: 이미지인 경우 `Content-Disposition: inline`.
### 3.2 `PUT /api/memos/<id>` (수정 및 보안 전이)
- **Status Change**: 암호화 해제 저장 시 `is_encrypted: 0`으로 DB 상태 업데이트.
- **Process**: `memo.py:update_memo`에서 `password` 유무에 따른 Re-encryption 수행.
+113
View File
@@ -0,0 +1,113 @@
# 📔 뇌사료 (Brain Dogfood) 사용자 매뉴얼 (v5.0+)
'뇌사료' 프로젝트에 오신 것을 환영합니다! 본 매뉴얼은 파편화된 영감을 체계적인 지식 성단(Nebula)으로 구축하는 데 필요한 모든 가이드를 제공합니다.
---
## 🌌 1. 지식 네뷸라 (시각화 탐색)
메인 전면에 펼쳐진 **지식 네뷸라**는 단순한 목록이 아닌 지식의 유기적인 지도를 보여줍니다.
- **노드(Node)**: 각각의 메모를 상징합니다.
- **크기**: 내용이 많거나 연결이 많을수록 노드가 거대해집니다.
- **색상**: 각 메모에 설정된 고유한 그룹 색상을 따릅니다.
- **🔒 아이콘**: 암호화된 메모임을 나타내며, 제목만 미리보기로 제공됩니다.
- **링크(Link)**:
- **실선**: `[[#ID]]` 문법으로 명시적으로 연결된 관계입니다.
- **인력(Gravity)**: 같은 그룹이나 공통 태그를 가진 메모들은 서로를 끌어당겨 가까이 배치됩니다.
---
## 🌡️ 3. 지식 성장 히트맵 (Heatmap) 사용법
사이드바에 위치한 히트맵은 사용자의 기록 강도를 시각적으로 보여줍니다.
- **기간 전환**: 히트맵 상단 제목 옆의 드롭다운을 통해 **1개월 / 3개월 / 6개월 / 1년** 단위를 선택할 수 있습니다.
- **상태 유지**: 한 번 선택한 기간은 브라우저에 저장되어 다음 접속 시에도 그대로 유지됩니다.
- **활동량 확인**: 각 칸에 마우스를 올리면 해당 날짜에 작성된 메모의 개수를 확인할 수 있습니다. 색상이 진해질수록(Cyan -> Purple) 더 많은 지식을 축적했음을 의미합니다.
---
## ✍️ 4. 메모 작성 및 스타일링
### 4.1 지식 연결 문법 (`[[#ID]]`)
메모 간의 명시적인 지식을 연결하려면 본문에 샵(#) 기호와 메모 번호를 사용하세요.
> 예: "이 개념은 `[[#12]]`에서 다룬 내용과 상충됩니다."
- **효과**: 뷰어에서 클릭 시 해당 메모로 바로 이동하며, 지식 네뷸라 상에 강력한 연결선이 형성됩니다.
### 4.2 컬러 텍스트 (Color Syntax)
에디터 상단 툴바의 **색상 선택 아이콘**을 사용하여 텍스트에 색상을 입힐 수 있습니다. 중요 키워드를 강조하여 지식의 가독성을 높이세요.
### 4.3 개별 메모 암호화
중요한 개인 정보나 아이디어는 암호화하여 보호할 수 있습니다.
- **사용법**: 편집기 하단의 **[암호화 사용]** 체크 -> 비밀번호 설정.
- **특약**: 암호화된 메모는 서버 측에서도 해독이 불가능하며, 비밀번호 분실 시 복구가 절대 불가능하므로 주의하세요.
- **복호화**: 작성된 암호화 메모 옆의 **🔓 해독** 버튼을 눌러 비밀번호 입력 시 일시적으로 내용을 확인할 수 있습니다.
---
---
## 🧠 5. AI 인텔리전스 (AI Insights)
### 5.1 AI 활성화 및 API 키 설정 (초보자 가이드)
'뇌사료'의 지능형 기능을 사용하려면 Google의 Gemini API 키가 필요합니다. 다음 단계에 따라 **1분 만에 무료로** 설정을 마칠 수 있습니다.
1. **키 발급**: [Google AI Studio (https://aistudio.google.com/app/apikey)](https://aistudio.google.com/app/apikey)에 접속합니다.
2. **프로젝트 생성**: "Create API key in new project" 버튼을 누릅니다.
3. **키 복사**: 생성된 `AIza...`로 시작하는 긴 문자열을 복사합니다.
4. **서버 적용**: 본 프로젝트 폴더의 `.env` 파일을 열고 `GEMINI_API_KEY=` 뒤에 복사한 키를 붙여넣고 저장합니다.
5. **활성화**: 앱 우상단 **[⚙️ 설정]** -> **AI 기능 활성화** 체크박스를 켜고 저장합니다.
> [!TIP]
> - API 키 발급은 완전히 무료이며, 개인적인 용도로는 충분한 할당량이 제공됩니다.
> - 키가 없더라도 메모 작성 및 시각화 등 기본적인 기능은 "NO AI" 모드로 완벽하게 작동합니다.
### 5.2 주요 기능
- **자동 요약**: 방대한 내용을 AI가 1~2문장의 핵심 문장으로 압축해줍니다.
- **스마트 태그**: 본문을 분석하여 자동으로 추천 태그를 생성합니다.
- **추론형 배치**: AI가 생성한 태그를 기반으로 지식 네뷸라 상에서 비슷한 맥락의 메모들이 자동으로 성단을 형성합니다.
---
---
## ⌨️ 6. 단축키 및 작업 효율 (Shortcuts)
'뇌사료'는 마우스 없이도 거의 모든 작업을 수행할 수 있도록 강력한 **Ctrl 기반** 단축키를 지원합니다.
### 6.1 전역 내비게이션
- **`Alt + `** (Backtick): ⚡ **Quake 스타일 새 메모** (영감을 즉시 기록)
- **`Ctrl + Shift + N`**: 새 메모 작성기 열기 📝
- **`Ctrl + Shift + G`**: 지식 네뷸라(시각화) 열기 🕸️
- **`Ctrl + Shift + E`**: 지식 탐색기(사이드바) 열기 🔍
- **`Ctrl + Shift + C`**: 사이드바 캘린더 토글 📅
- **`Ctrl + Shift + Q`** 또는 **`ESC`**: 모든 모달 및 드로어 닫기
### 6.2 에디터 작업
- **`Ctrl + Enter`** 또는 **`Ctrl + S`**: **현재 메모 저장 및 게시** 💾
- **`/` (Slash)**: 슬래시 명령 오픈 (AI 요약, 서식 등) 🪄
- **`Shift + ESC`**: 작성 취소 및 닫기
### 6.3 마우스 팁
- **`Alt + 클릭`**: 메인 그리드에서 메모를 즉시 수정 ✏️
---
## 🚀 7. 운영 및 관리 (Ops & Backup)
### 7.1 정밀 배포 (`deploy.py`)
개발 환경에서 작업한 코드를 서버로 안전하게 배포합니다.
```bash
python deploy.py
```
### 7.2 재난 복구 백업 (`backup.py`)
서버의 모든 핵심 데이터를 압축하여 안전하게 보관합니다.
```bash
python backup.py
```
---
**지식의 우주를 마음껏 탐험하세요!** 🛸🌌
+49
View File
@@ -0,0 +1,49 @@
# 📱 v2.0 모바일 최적화 로드맵 (Roadmap)
본 문서는 `뇌사료` 프로젝트 v2.0에서 다룰 모바일 해상도 지원 및 UX 최적화 범위에 대한 정밀 분석 내역을 담고 있습니다.
## 1. 레이아웃 및 응답성 (Responsive Layout)
### 1.1 오프캔버스 사이드바 (Off-canvas Sidebar)
- **현황**: 사이드바가 가로 공간을 상시 점유하여 모바일에서 가독성 저해.
- **v2.0 목표**: 768px 이하에서 사이드바를 기본적으로 숨기고, 상단 햄버거 메뉴 버튼 클릭 시 왼쪽에서 슬라이드되는 드로어(Drawer) 방식으로 전환.
- **기술적 구현**: `transform: translateX(-100%)``z-index`를 활용한 오버레이 처리.
### 1.2 가변형 메모 그리드 (Fluid Masonry Grid)
- **현황**: 열 개수가 고정되거나 모바일에서 너무 좁게 보임.
- **v2.0 목표**: 모바일 해상도에서 메모 카드를 1열(100%)로 강제하여 텍스트 가독성 극대화. 패딩 값을 `3rem`에서 `1rem`으로 축소.
---
## 2. 입력 및 작성 경험 (Composer UX)
### 2.1 인터페이스 수직 스태킹 (Vertical Stacking)
- **현황**: 제목, 그룹, 태그 입력창이 가로로 배치되어 모바일에서 잘림.
- **v2.0 목표**: `flex-direction: column`을 적용하여 제목 -> 메타 정보(그룹/태그) -> 에디터 순서로 자연스럽게 흐르도록 재배치.
### 2.2 모바일 에디터 최적화
- **v2.0 목표**: Toast UI 에디터의 불필요한 툴바를 숨기고, 모바일 자판이 올라와도 입력 영역이 충분히 확보되도록 높이 자동 조절 기능 추가.
---
## 3. 터치 및 제스처 (Touch & Gestures)
### 3.1 터치 타겟(Touch Target) 확장
- **목표**: 모든 버튼 및 상호작용 요소의 크기를 최소 44x44px 이상으로 확보하여 오클릭 방지.
- **세부 사항**: 삭제, 수정, 암호화 토글 버튼의 여백 조정.
### 3.2 제스처 지원
- **목표**: 화면 왼쪽 가장자리를 스와이프하여 사이드바를 여는 제스처 로직 검토.
---
## 4. 기술적 체크리스트 (Technical Checklist)
- [ ] `@media (max-width: 768px)` 기준점 확립.
- [ ] 터치 이벤트(`touchstart`, `touchend`) 처리 최적화.
- [ ] `safe-area-inset-bottom` 등 최신 모바일 기기의 노치 및 하단 바 대응.
- [ ] 모바일 데이터 환경을 고려한 이미지 지연 로딩(Lazy Loading) 적용.
- [ ] 모달 창의 `width: 100%; height: 100%;` 전체 화면 모드 지원.
---
*Generated for Brain Dogfood v2.0 Development*
+6
View File
@@ -0,0 +1,6 @@
Flask==3.0.2
google-genai
python-dotenv==1.0.1
cryptography==42.0.5
paramiko==3.4.0
scp==0.14.5
+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');
+219
View File
@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="app_name">Brain Dogfood</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
<!-- Toast UI Editor -->
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" />
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/theme/toastui-editor-dark.min.css" />
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
<!-- TUI Color Picker & Color Syntax Plugin -->
<link rel="stylesheet" href="https://uicdn.toast.com/tui-color-picker/latest/tui-color-picker.min.css" />
<script src="https://uicdn.toast.com/tui-color-picker/latest/tui-color-picker.min.js"></script>
<link rel="stylesheet" href="https://uicdn.toast.com/editor-plugin-color-syntax/latest/toastui-editor-plugin-color-syntax.min.css" />
<script src="https://uicdn.toast.com/editor-plugin-color-syntax/latest/toastui-editor-plugin-color-syntax.min.js"></script>
<!-- D3.js for Knowledge Nebula -->
<script src="https://d3js.org/d3.v7.min.js"></script>
<link rel="stylesheet" href="/static/style.css?v=1.4">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
</head>
<body>
<aside class="sidebar" id="sidebar">
<div class="sidebar-header" style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 2.5rem;">
<h1 class="logo">🧠 <span class="text" data-i18n="app_name">Brain Dogfood</span></h1>
<button id="sidebarToggle" class="sidebar-toggle" data-i18n-title="nav_toggle"></button>
</div>
<div class="sidebar-content">
<ul class="nav" id="systemNav">
<li class="active" data-group="all" data-i18n-title="nav_all"><span class="icon">💡</span> <span class="text" data-i18n="nav_all">All Knowledge</span></li>
<li data-group="files" data-i18n-title="nav_files"><span class="icon">📂</span> <span class="text" data-i18n="nav_files">Files</span></li>
<li data-group="done" data-i18n-title="nav_done"><span class="icon"></span> <span class="text" data-i18n="nav_done">Done</span></li>
</ul>
<div class="sidebar-section">
<button id="openExplorerBtn" class="action-btn explorer-btn" style="width: 100%; justify-content: flex-start; margin-top: 15px; padding: 12px 15px; background: rgba(56, 189, 248, 0.1); border: 1px solid rgba(56, 189, 248, 0.2); color: var(--accent); font-weight: 600; border-radius: 12px;">
<span class="icon">🔍</span> <span class="text" data-i18n="nav_explorer">Knowledge Explorer</span>
</button>
</div>
<div class="sidebar-section">
<div id="calendarHeader" class="section-title" style="cursor: pointer; display: flex; align-items: center; justify-content: space-between; padding: 10px 15px; border-radius: 8px; margin-top: 10px; transition: background 0.2s;">
<span style="font-size: 0.9rem; font-weight: 600; color: var(--muted);"><span class="icon">📅</span> <span class="text" data-i18n="nav_calendar">Calendar</span></span>
<span id="calendarToggleIcon" style="font-size: 0.8rem; color: var(--muted);"></span>
</div>
<div id="calendarContainer" class="calendar-content">
<!-- JS will render calendar here -->
</div>
</div>
<div class="sidebar-section">
<div id="heatmapContainer">
<!-- JS will render heatmap here -->
</div>
</div>
<div class="sidebar-section">
<button id="openGraphBtn" class="action-btn" style="width: 100%; justify-content: flex-start; margin-top: 10px; padding: 10px 15px; background: rgba(139, 92, 246, 0.1); border: 1px solid rgba(139, 92, 246, 0.2); color: var(--ai-accent);">
<span class="icon">🕸️</span> <span class="text" data-i18n="nav_nebula">Knowledge Nebula</span>
</button>
</div>
</div>
<div class="sidebar-footer">
<button id="logoutBtn" class="action-btn" style="color: #ff4d4d;" data-i18n-tooltip="tooltip_logout">
<span class="icon">🚪</span> <span class="text" data-i18n="nav_logout">Logout</span>
</button>
<button id="settingsBtn" class="action-btn" data-i18n-tooltip="tooltip_settings">
<span class="icon">⚙️</span>
</button>
</div>
</aside>
<main class="content">
<div class="topbar">
<button id="mobileMenuBtn" class="sidebar-toggle" style="display: none; margin-right: 15px;"></button>
<div class="search-bar">
<span class="search-icon">🔍</span>
<input type="text" id="searchInput" data-i18n-placeholder="search_placeholder">
</div>
</div>
<div class="composer-wrapper">
<!-- Accordion default closed state -->
<div id="composerTrigger" class="glass-panel" style="cursor: text;">
<span style="color: var(--muted); font-size: 1.1rem; font-weight: 600;" data-i18n="composer_placeholder_trigger">Capture knowledge or drop files...</span>
</div>
<!-- Actual Composer -->
<form id="composer" class="glass-panel" style="display: none;">
<input type="hidden" id="editingMemoId" value="">
<div style="display: flex; gap:10px; align-items:center; margin-bottom: 10px;">
<input type="text" id="memoTitle" data-i18n-placeholder="composer_title" autocomplete="off" style="flex: 1;">
<button type="button" id="foldBtn" class="action-btn" style="height:35px; width:35px; padding:0;" data-i18n-title="tooltip_fold"></button>
</div>
<div class="meta-inputs" style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center;">
<input type="text" id="memoGroup" data-i18n-placeholder="composer_group" class="meta-field" style="width: 120px;">
<input type="text" id="memoTags" data-i18n-placeholder="composer_tags" class="meta-field" style="flex: 1;">
<button type="button" id="encryptionToggle" class="action-btn" data-i18n-title="composer_encrypt" style="height:34px; padding:0 10px;">🔓</button>
<input type="password" id="memoPassword" data-i18n-placeholder="composer_password" class="meta-field" style="width: 120px; display: none;">
</div>
<div class="editor-resize-wrapper">
<div id="editor"></div>
</div>
<!-- Pending Attachments list in Composer -->
<div id="editorAttachments" class="memo-attachments" style="margin-top: 15px;"></div>
<!-- 키보드 단축키 힌트 (토글) -->
<div id="shortcutHint" class="shortcut-hint-bar">
<button type="button" id="shortcutToggle" class="shortcut-toggle-btn" data-i18n="shortcuts_label">⌨️ Shortcuts</button>
<div id="shortcutDetails" class="shortcut-details" style="display: none;">
<span class="sk"><kbd>Ctrl</kbd>+<kbd>Enter</kbd> <span data-i18n="shortcut_save">Save</span></span>
<span class="sk"><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd> / <kbd>Alt</kbd>+<kbd>`</kbd> <span data-i18n="shortcut_new">New Memo</span></span>
<span class="sk"><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd> <span data-i18n="shortcut_nebula">Nebula</span></span>
<span class="sk"><kbd>/</kbd> <span data-i18n="shortcut_slash">Slash Commands</span></span>
<span class="sk"><kbd>Alt</kbd>+<kbd>Click</kbd> <span data-i18n="shortcut_edit">Quick Edit</span></span>
</div>
</div>
<div class="composer-actions" style="display: flex; gap: 10px; margin-top: 15px; justify-content: flex-end;">
<button type="button" id="discardBtn" class="action-btn" style="background: rgba(255, 77, 77, 0.1); color: #ff4d4d; border-color: rgba(255, 77, 77, 0.2);" data-i18n="composer_discard">Discard (Delete)</button>
<button type="submit" id="submitBtn" class="primary-btn" data-i18n="composer_save">Save Memo</button>
</div>
</form>
</div>
<div class="masonry-grid" id="memoGrid">
<!-- Memos loaded here -->
</div>
<div id="scrollSentinel" style="height: 50px; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 0.9rem;">
</div>
</main>
<!-- Modal for viewing memo details/links -->
<div id="memoModal" class="modal">
<div class="modal-content glass-panel" id="modalContent"></div>
</div>
<!-- Settings Modal -->
<div id="settingsModal" class="modal">
<div class="modal-content glass-panel" style="max-width: 400px; padding: 25px;">
<h2 style="margin-bottom: 20px; font-weight: 800; background: linear-gradient(135deg, #38bdf8, #8b5cf6); -webkit-background-clip: text; -webkit-text-fill-color: transparent;" data-i18n="settings_title">⚙️ Settings</h2>
<div class="settings-grid">
<label data-i18n="settings_bg">전체 배경색</label>
<input type="color" id="set-bg" data-var="--bg">
<label data-i18n="settings_sidebar">사이드바 색상</label>
<input type="color" id="set-sidebar" data-var="--sidebar">
<label data-i18n="settings_card">메모지 색상</label>
<input type="color" id="set-card" data-var="--card">
<label data-i18n="settings_security">보안 테두리색</label>
<input type="color" id="set-encrypted" data-var="--encrypted-border">
<label data-i18n="settings_ai_accent">AI 분석 강조색</label>
<input type="color" id="set-ai" data-var="--ai-accent">
<label style="font-weight: 800; color: var(--ai-accent);" data-i18n="settings_ai_enable">AI 기능 활성화</label>
<input type="checkbox" id="set-enable-ai" style="width: 20px; height: 20px; cursor: pointer;">
</div>
<div class="settings-grid">
<label data-i18n="settings_lang">언어 설정</label>
<select id="set-lang" class="meta-field" style="width: 100px;">
<option value="ko">한국어</option>
<option value="en">English</option>
</select>
</div>
<div class="settings-actions">
<button id="resetThemeBtn" class="action-btn" style="font-size: 0.85rem;" data-i18n="settings_reset">Reset</button>
<button id="saveThemeBtn" class="primary-btn" data-i18n="settings_save">Save</button>
<button id="closeSettingsBtn" class="action-btn" data-i18n="settings_close">Close</button>
</div>
</div>
</div>
<!-- AI Loading Overlay (Optional but nice) -->
<div id="loadingOverlay" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); backdrop-filter:blur(5px); z-index:2000; flex-direction:column; justify-content:center; align-items:center;">
<div class="spinner"></div>
<p style="margin-top:20px; font-weight:800; color:var(--accent);" data-i18n="msg_ai_loading">AI is analyzing the memo...</p>
</div>
<!-- Sidebar Overlay for mobile -->
<div id="sidebarOverlay" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.4); z-index:900; backdrop-filter:blur(2px);"></div>
<!-- Graph Modal -->
<div id="graphModal" class="modal">
<div class="modal-content glass-panel" style="width: 90%; height: 90%; max-width: none; overflow: hidden; position: relative; padding: 0; background: radial-gradient(circle at center, #1e293b 0%, #0f172a 100%);">
<div id="graphContainer" style="width: 100%; height: 100%; background-image: radial-gradient(circle, rgba(255,255,255,0.05) 1px, transparent 1px); background-size: 50px 50px;"></div>
<button id="closeGraphBtn" style="position: absolute; top: 20px; right: 20px; z-index: 100; background: rgba(0,0,0,0.5); border: none; color: white; border-radius: 50%; width: 40px; height: 40px; cursor: pointer; font-size: 20px;">×</button>
</div>
</div>
<!-- Knowledge Explorer Drawer -->
<div id="knowledgeDrawer" class="drawer">
<div class="drawer-header">
<h3 data-i18n="nav_explorer">🔍 Knowledge Explorer</h3>
<button id="closeDrawerBtn" class="close-btn">×</button>
</div>
<div id="drawerContent" class="drawer-body">
<!-- Groups/Tags will be injected here -->
</div>
</div>
<script type="module" src="/static/app.js?v=2.2"></script>
</body>
</html>
+98
View File
@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="login_title">뇌사료 | 보안 로그인</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/login.css?v=1.0">
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="logo-area">
<h1 class="logo">🧠 <span data-i18n="app_name">Brain Dogfood</span></h1>
<p class="tagline" data-i18n="login_welcome">Welcome to your intelligent knowledge base.</p>
</div>
<div class="input-group">
<label for="username" data-i18n="login_id">Auth ID</label>
<div class="input-wrapper">
<input type="text" id="username" placeholder="Username" autocomplete="username" required>
</div>
</div>
<div class="input-group">
<label for="password" data-i18n="login_pw">Password</label>
<div class="input-wrapper">
<input type="password" id="password" placeholder="••••••••" autocomplete="current-password" required>
</div>
</div>
<button class="login-btn" id="loginBtn" data-i18n="login_btn">Login</button>
<div class="error-msg" id="errorMsg" data-i18n="msg_auth_failed" style="display:none;">Authentication failed.</div>
<div class="login-card-footer">
&copy; 2026 개밥마스터 Personal Knowledge Base.
</div>
</div>
</div>
<script type="module">
import { I18nManager } from '/static/js/utils/I18nManager.js';
const lang = "{{ lang }}"; // Server-injected lang
await I18nManager.init(lang);
const loginBtn = document.getElementById('loginBtn');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const errorMsg = document.getElementById('errorMsg');
loginBtn.addEventListener('click', async () => {
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (!username || !password) return;
errorMsg.style.display = 'none';
loginBtn.innerText = I18nManager.t('msg_authenticating');
loginBtn.disabled = true;
try {
const res = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (res.ok) {
window.location.href = '/';
} else {
errorMsg.style.display = 'block';
errorMsg.innerText = data.error || I18nManager.t('msg_auth_failed');
loginBtn.innerText = I18nManager.t('login_btn');
loginBtn.disabled = false;
}
} catch (err) {
console.error(err);
alert(I18nManager.t('msg_network_error'));
loginBtn.innerText = I18nManager.t('login_btn');
loginBtn.disabled = false;
}
});
// Enter key to login
[usernameInput, passwordInput].forEach(input => {
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') loginBtn.click();
});
});
</script>
</body>
</html>