mirror of
https://github.com/sotam0316/brain_dogfood.git
synced 2026-04-24 19:48:35 +09:00
v1.5: Integrated optional category feature, i18n stabilization, and documentation update
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
[flake8]
|
||||||
|
# E701: 한 줄에 콜론(:) 사용 (Multiple statements on one line (colon))
|
||||||
|
# E702: 한 줄에 세미콜론(;) 사용 (Multiple statements on one line (semicolon))
|
||||||
|
# 위 항목들은 가독성을 위한 스타일 가이드이므로, 개발 효율을 위해 영구 무시 설정함.
|
||||||
|
ignore = E701, E702
|
||||||
|
max-line-length = 120
|
||||||
|
exclude = .git,__pycache__,docs,old,build,dist,venv
|
||||||
+30
-6
@@ -1,10 +1,34 @@
|
|||||||
# Brain Dogfood Public Repo Gitignore
|
# [뇌사료] Git 배포용 .gitignore
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.venv/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Configuration & Secrets
|
||||||
.env
|
.env
|
||||||
config.json
|
config.json
|
||||||
memos.db
|
|
||||||
__pycache__/
|
# Data & Databases
|
||||||
*.pyc
|
|
||||||
logs/
|
|
||||||
data/
|
data/
|
||||||
.vscode/
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Media & Uploads
|
||||||
|
static/uploads/*
|
||||||
|
!static/uploads/.gitkeep
|
||||||
|
|
||||||
|
# Logs & Temporary
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
scratch/
|
||||||
|
.gemini/
|
||||||
.idea/
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# System
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ def create_app():
|
|||||||
app.config.update(
|
app.config.update(
|
||||||
SESSION_COOKIE_HTTPONLY=True,
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
SESSION_COOKIE_SAMESITE='Lax',
|
SESSION_COOKIE_SAMESITE='Lax',
|
||||||
SESSION_COOKIE_SECURE=False, # Set to True in production with HTTPS
|
SESSION_COOKIE_SECURE=os.getenv('SESSION_COOKIE_SECURE', 'False').lower() == 'true',
|
||||||
PERMANENT_SESSION_LIFETIME=3600 # 60 minutes (1 hour) session
|
PERMANENT_SESSION_LIFETIME=3600 # 60 minutes (1 hour) session
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -5,8 +5,9 @@ from flask import session, redirect, url_for, request, current_app # type: ignor
|
|||||||
def check_auth(username, password):
|
def check_auth(username, password):
|
||||||
"""
|
"""
|
||||||
환경 변수에 설정된 관리자 계정 정보와 일치하는지 확인합니다.
|
환경 변수에 설정된 관리자 계정 정보와 일치하는지 확인합니다.
|
||||||
|
ADMIN_USERNAME 또는 ADMIN_USER 중 하나를 사용합니다.
|
||||||
"""
|
"""
|
||||||
admin_user = os.getenv('ADMIN_USERNAME', 'admin')
|
admin_user = os.getenv('ADMIN_USERNAME') or os.getenv('ADMIN_USER') or 'admin'
|
||||||
admin_password = os.getenv('ADMIN_PASSWORD', 'admin')
|
admin_password = os.getenv('ADMIN_PASSWORD', 'admin')
|
||||||
return username == admin_user and password == admin_password
|
return username == admin_user and password == admin_password
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ def init_db():
|
|||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
c.execute("ALTER TABLE memos ADD COLUMN category TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# 2. Separate Tags Table (Normalized)
|
# 2. Separate Tags Table (Normalized)
|
||||||
c.execute('''
|
c.execute('''
|
||||||
|
|||||||
+4
-1
@@ -1,4 +1,4 @@
|
|||||||
from flask import Blueprint, request, jsonify, session, redirect, url_for # type: ignore
|
from flask import Blueprint, request, jsonify, session, redirect, url_for, current_app # type: ignore
|
||||||
from ..auth import check_auth
|
from ..auth import check_auth
|
||||||
from ..utils.i18n import _t
|
from ..utils.i18n import _t
|
||||||
|
|
||||||
@@ -13,7 +13,10 @@ def login():
|
|||||||
if check_auth(username, password):
|
if check_auth(username, password):
|
||||||
session.permanent = True # Enable permanent session to use LIFETIME config
|
session.permanent = True # Enable permanent session to use LIFETIME config
|
||||||
session['logged_in'] = True
|
session['logged_in'] = True
|
||||||
|
current_app.logger.info(f"AUTH: Success login for user '{username}' from {request.remote_addr}")
|
||||||
return jsonify({'message': 'Logged in successfully'})
|
return jsonify({'message': 'Logged in successfully'})
|
||||||
|
|
||||||
|
current_app.logger.warning(f"AUTH: Failed login attempt for user '{username}' from {request.remote_addr}")
|
||||||
return jsonify({'error': _t('msg_auth_failed')}), 401
|
return jsonify({'error': _t('msg_auth_failed')}), 401
|
||||||
|
|
||||||
@auth_bp.route('/logout')
|
@auth_bp.route('/logout')
|
||||||
|
|||||||
+3
-2
@@ -1,4 +1,4 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, session, current_app # type: ignore
|
from flask import Blueprint, render_template, redirect, url_for, session # type: ignore
|
||||||
from ..auth import login_required
|
from ..auth import login_required
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
@@ -22,6 +22,7 @@ def login_page():
|
|||||||
try:
|
try:
|
||||||
with open(config_path, 'r', encoding='utf-8') as f:
|
with open(config_path, 'r', encoding='utf-8') as f:
|
||||||
lang = json.load(f).get('lang', 'ko')
|
lang = json.load(f).get('lang', 'ko')
|
||||||
except: pass
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return render_template('login.html', lang=lang)
|
return render_template('login.html', lang=lang)
|
||||||
|
|||||||
+44
-10
@@ -4,7 +4,7 @@ from ..database import get_db
|
|||||||
from ..auth import login_required
|
from ..auth import login_required
|
||||||
from ..constants import GROUP_DONE, GROUP_DEFAULT
|
from ..constants import GROUP_DONE, GROUP_DEFAULT
|
||||||
from ..utils.i18n import _t
|
from ..utils.i18n import _t
|
||||||
from ..utils import extract_links
|
from ..utils import extract_links, parse_metadata, parse_and_clean_metadata, generate_auto_title
|
||||||
from ..security import encrypt_content, decrypt_content
|
from ..security import encrypt_content, decrypt_content
|
||||||
|
|
||||||
memo_bp = Blueprint('memo', __name__)
|
memo_bp = Blueprint('memo', __name__)
|
||||||
@@ -17,8 +17,11 @@ def get_memos():
|
|||||||
group = request.args.get('group', 'all')
|
group = request.args.get('group', 'all')
|
||||||
query = request.args.get('query', '')
|
query = request.args.get('query', '')
|
||||||
date = request.args.get('date', '')
|
date = request.args.get('date', '')
|
||||||
|
category = request.args.get('category')
|
||||||
if date in ('null', 'undefined'):
|
if date in ('null', 'undefined'):
|
||||||
date = ''
|
date = ''
|
||||||
|
if category in ('null', 'undefined'):
|
||||||
|
category = ''
|
||||||
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
@@ -52,8 +55,13 @@ def get_memos():
|
|||||||
where_clauses.append("created_at LIKE ?")
|
where_clauses.append("created_at LIKE ?")
|
||||||
params.append(f"{date}%")
|
params.append(f"{date}%")
|
||||||
|
|
||||||
# 4. 초기 로드 시 최근 5일 강조 (필터가 없는 경우에만 적용)
|
# 4. 카테고리 필터링
|
||||||
if offset == 0 and group == 'all' and not query and not date:
|
if category:
|
||||||
|
where_clauses.append("category = ?")
|
||||||
|
params.append(category)
|
||||||
|
|
||||||
|
# 5. 초기 로드 시 최근 5일 강조 (필터가 없는 경우에만 적용)
|
||||||
|
if offset == 0 and group == 'all' and not query and not date and not category:
|
||||||
start_date = (datetime.datetime.now() - datetime.timedelta(days=5)).isoformat()
|
start_date = (datetime.datetime.now() - datetime.timedelta(days=5)).isoformat()
|
||||||
where_clauses.append("(updated_at >= ? OR is_pinned = 1)")
|
where_clauses.append("(updated_at >= ? OR is_pinned = 1)")
|
||||||
params.append(start_date)
|
params.append(start_date)
|
||||||
@@ -155,7 +163,18 @@ def create_memo():
|
|||||||
user_tags = data.get('tags', [])
|
user_tags = data.get('tags', [])
|
||||||
is_encrypted = 1 if data.get('is_encrypted') else 0
|
is_encrypted = 1 if data.get('is_encrypted') else 0
|
||||||
password = data.get('password', '').strip()
|
password = data.get('password', '').strip()
|
||||||
|
category = data.get('category')
|
||||||
|
|
||||||
|
# 본문 기반 메타데이터 통합 및 정리 ($그룹, #태그 하단 이동)
|
||||||
|
new_content, final_group, final_tags = parse_and_clean_metadata(content, ui_group=group_name, ui_tags=user_tags)
|
||||||
|
content = new_content
|
||||||
|
group_name = final_group
|
||||||
|
user_tags = final_tags
|
||||||
|
|
||||||
|
# 제목 자동 생성 (비어있을 경우)
|
||||||
|
if not title:
|
||||||
|
title = generate_auto_title(content)
|
||||||
|
|
||||||
if is_encrypted and password:
|
if is_encrypted and password:
|
||||||
content = encrypt_content(content, password)
|
content = encrypt_content(content, password)
|
||||||
elif is_encrypted and not password:
|
elif is_encrypted and not password:
|
||||||
@@ -169,9 +188,9 @@ def create_memo():
|
|||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
try:
|
try:
|
||||||
c.execute('''
|
c.execute('''
|
||||||
INSERT INTO memos (title, content, color, is_pinned, status, group_name, is_encrypted, created_at, updated_at)
|
INSERT INTO memos (title, content, color, is_pinned, status, group_name, category, is_encrypted, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
''', (title, content, color, is_pinned, status, group_name, is_encrypted, now, now))
|
''', (title, content, color, is_pinned, status, group_name, category, is_encrypted, now, now))
|
||||||
memo_id = c.lastrowid
|
memo_id = c.lastrowid
|
||||||
|
|
||||||
for tag in user_tags:
|
for tag in user_tags:
|
||||||
@@ -208,26 +227,39 @@ def update_memo(memo_id):
|
|||||||
user_tags = data.get('tags')
|
user_tags = data.get('tags')
|
||||||
is_encrypted = data.get('is_encrypted')
|
is_encrypted = data.get('is_encrypted')
|
||||||
password = data.get('password', '').strip()
|
password = data.get('password', '').strip()
|
||||||
|
category = data.get('category')
|
||||||
|
|
||||||
now = datetime.datetime.now().isoformat()
|
now = datetime.datetime.now().isoformat()
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
|
|
||||||
# 보안: 암호화된 메모 수정 시 비밀번호 검증
|
# 보안: 암호화된 메모 수정 시 비밀번호 검증
|
||||||
c.execute('SELECT content, is_encrypted FROM memos WHERE id = ?', (memo_id,))
|
c.execute('SELECT content, is_encrypted, group_name FROM memos WHERE id = ?', (memo_id,))
|
||||||
memo = c.fetchone()
|
memo = c.fetchone()
|
||||||
if memo and memo['is_encrypted']:
|
if memo and memo['is_encrypted']:
|
||||||
# 암호화된 메모지만 '암호화 해제(is_encrypted=0)' 요청이 온 경우는
|
|
||||||
# 비밀번호 없이도 수정을 시도할 수 있어야 할까? (아니오, 인증이 필요함)
|
|
||||||
if not password:
|
if not password:
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'error': _t('msg_encrypted_locked')}), 403
|
return jsonify({'error': _t('msg_encrypted_locked')}), 403
|
||||||
|
|
||||||
# 비밀번호가 맞는지 검증 (복호화 시도)
|
|
||||||
if decrypt_content(memo['content'], password) is None:
|
if decrypt_content(memo['content'], password) is None:
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'error': _t('msg_auth_failed')}), 403
|
return jsonify({'error': _t('msg_auth_failed')}), 403
|
||||||
|
|
||||||
|
# 본문 기반 메타데이터 통합 및 정리 ($그룹, #태그 하단 이동)
|
||||||
|
if content is not None:
|
||||||
|
new_content, final_group, final_tags = parse_and_clean_metadata(
|
||||||
|
content,
|
||||||
|
ui_group=(group_name or memo['group_name']),
|
||||||
|
ui_tags=(user_tags if user_tags is not None else [])
|
||||||
|
)
|
||||||
|
content = new_content
|
||||||
|
group_name = final_group
|
||||||
|
user_tags = final_tags
|
||||||
|
|
||||||
|
# 제목 자동 생성 (비어있을 경우)
|
||||||
|
if title == "":
|
||||||
|
title = generate_auto_title(content or "")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
updates = ['updated_at = ?']
|
updates = ['updated_at = ?']
|
||||||
params = [now]
|
params = [now]
|
||||||
@@ -252,6 +284,8 @@ def update_memo(memo_id):
|
|||||||
updates.append('group_name = ?'); params.append(group_name.strip() or GROUP_DEFAULT)
|
updates.append('group_name = ?'); params.append(group_name.strip() or GROUP_DEFAULT)
|
||||||
if is_encrypted is not None:
|
if is_encrypted is not None:
|
||||||
updates.append('is_encrypted = ?'); params.append(1 if is_encrypted else 0)
|
updates.append('is_encrypted = ?'); params.append(1 if is_encrypted else 0)
|
||||||
|
if category is not None:
|
||||||
|
updates.append('category = ?'); params.append(category)
|
||||||
|
|
||||||
params.append(memo_id)
|
params.append(memo_id)
|
||||||
c.execute(f"UPDATE memos SET {', '.join(updates)} WHERE id = ?", params)
|
c.execute(f"UPDATE memos SET {', '.join(updates)} WHERE id = ?", params)
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ DEFAULT_SETTINGS = {
|
|||||||
"encrypted_border": "#00f3ff",
|
"encrypted_border": "#00f3ff",
|
||||||
"ai_accent": "#8b5cf6",
|
"ai_accent": "#8b5cf6",
|
||||||
"enable_ai": True,
|
"enable_ai": True,
|
||||||
"lang": "ko"
|
"lang": "ko",
|
||||||
|
"enable_categories": False, # 카테고리 기능 활성화 여부 (고급 옵션)
|
||||||
|
"categories": [], # 무제한 전체 목록
|
||||||
|
"pinned_categories": [] # 최대 3개 (Alt+2~4 할당용)
|
||||||
}
|
}
|
||||||
|
|
||||||
@settings_bp.route('/api/settings', methods=['GET'])
|
@settings_bp.route('/api/settings', methods=['GET'])
|
||||||
|
|||||||
+70
-5
@@ -1,26 +1,91 @@
|
|||||||
import re
|
import re
|
||||||
from ..constants import GROUP_DEFAULT
|
from ..constants import GROUP_DEFAULT
|
||||||
|
|
||||||
def parse_metadata(text):
|
def parse_metadata(text, default_group=GROUP_DEFAULT):
|
||||||
"""
|
"""
|
||||||
텍스트에서 ##그룹명 과 #태그 추출 유틸리티.
|
텍스트에서 $그룹명 과 #태그 추출 유틸리티.
|
||||||
|
그룹은 첫 번째 매칭된 것만 반환합니다.
|
||||||
"""
|
"""
|
||||||
group_name = GROUP_DEFAULT
|
group_name = default_group
|
||||||
tags = []
|
tags = []
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
return group_name, tags
|
return group_name, tags
|
||||||
|
|
||||||
group_match = re.search(r'##(\S+)', text)
|
# $그룹명 추출 (단어 경계 고려, 첫 번째 매칭만)
|
||||||
|
group_match = re.search(r'\$(\w+)', text)
|
||||||
if group_match:
|
if group_match:
|
||||||
group_name = group_match.group(1)
|
group_name = group_match.group(1)
|
||||||
|
|
||||||
tag_matches = re.finditer(r'(?<!#)#(\S+)', text)
|
# #태그 추출 (마크다운 헤더 방지: 최소 한 개의 공백이나 시작 지점 뒤에 오는 #)
|
||||||
|
tag_matches = re.finditer(r'(?<!#)#(\w+)', text)
|
||||||
for match in tag_matches:
|
for match in tag_matches:
|
||||||
tags.append(match.group(1))
|
tags.append(match.group(1))
|
||||||
|
|
||||||
return group_name, list(set(tags))
|
return group_name, list(set(tags))
|
||||||
|
|
||||||
|
def parse_and_clean_metadata(content, ui_group=GROUP_DEFAULT, ui_tags=None):
|
||||||
|
"""
|
||||||
|
본문에서 메타데이터($ , #)를 추출하고 삭제한 뒤, UI 입력값과 합쳐 최하단에 재배치합니다.
|
||||||
|
"""
|
||||||
|
if ui_tags is None: ui_tags = []
|
||||||
|
if not content:
|
||||||
|
return content, ui_group, ui_tags
|
||||||
|
|
||||||
|
# 1. 기존에 생성된 푸터 블록(수평선 + 메타데이터)을 모두 제거
|
||||||
|
# 전후 공백을 제거한 후, 하단의 수평선(---, ***, ___)과 메타데이터 블록을 반복적으로 탐색하여 제거합니다.
|
||||||
|
content = content.strip()
|
||||||
|
# 패턴: (공백+수평선+공백 + (메타데이터 또는 공백))이 문자열 끝에 1회 이상 반복
|
||||||
|
content = re.sub(r'(?:\s*[\*\-\_]{3,}\s*(?:[\$\#][\s\S]*?)?\s*)+$', '', content).strip()
|
||||||
|
|
||||||
|
# 2. 본문에서 기호 정보 추출
|
||||||
|
content_group, content_tags = parse_metadata(content)
|
||||||
|
|
||||||
|
# 3. 본문에서 기호 패턴 삭제
|
||||||
|
# $그룹 삭제
|
||||||
|
content = re.sub(r'\$\w+', '', content)
|
||||||
|
# #태그 삭제 (헤더 제외)
|
||||||
|
content = re.sub(r'(?<!#)#\w+', '', content)
|
||||||
|
content = content.strip()
|
||||||
|
|
||||||
|
# 4. 데이터 통합
|
||||||
|
# 본문에 적힌 그룹이 있다면 UI 선택값보다 우선함
|
||||||
|
final_group = content_group if content_group != GROUP_DEFAULT else ui_group
|
||||||
|
# 태그는 모두 합침
|
||||||
|
final_tags = list(set(ui_tags + content_tags))
|
||||||
|
|
||||||
|
# 5. 푸터 재생성
|
||||||
|
footer_parts = []
|
||||||
|
if final_group and final_group != GROUP_DEFAULT:
|
||||||
|
footer_parts.append(f"${final_group}")
|
||||||
|
if final_tags:
|
||||||
|
footer_tags = " ".join([f"#{t}" for t in sorted(final_tags)])
|
||||||
|
footer_parts.append(footer_tags)
|
||||||
|
|
||||||
|
final_content = content
|
||||||
|
if footer_parts:
|
||||||
|
final_content += "\n\n---\n" + "\n".join(footer_parts)
|
||||||
|
|
||||||
|
return final_content, final_group, final_tags
|
||||||
|
|
||||||
|
def generate_auto_title(content):
|
||||||
|
"""
|
||||||
|
본문에서 제목을 추출합니다. (첫 줄 기준, 영문 20자/한글 10자 내외)
|
||||||
|
"""
|
||||||
|
if not content:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 푸터 제외하고 순수 본문만 추출하여 제목 생성
|
||||||
|
main_content = re.split(r'\n+---\n', content)[0].strip()
|
||||||
|
if not main_content: return ""
|
||||||
|
|
||||||
|
lines = main_content.split('\n')
|
||||||
|
first_line = lines[0].strip()
|
||||||
|
# 마크다운 헤더 기호(#) 제거
|
||||||
|
first_line = re.sub(r'^#+\s+', '', first_line).strip()
|
||||||
|
|
||||||
|
return first_line[:20]
|
||||||
|
|
||||||
def extract_links(text):
|
def extract_links(text):
|
||||||
"""
|
"""
|
||||||
텍스트에서 [[#ID]] 형태의 내부 링크를 찾아 ID 목록(정수)을 반환합니다.
|
텍스트에서 [[#ID]] 형태의 내부 링크를 찾아 ID 목록(정수)을 반환합니다.
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# 버그 리포트: 히트맵 및 달력 날짜 필터링 실패
|
|
||||||
|
|
||||||
## 버그 내용
|
|
||||||
- **현상**: 히트맵이나 달력에서 특정 날짜를 클릭했을 때, 해당 날짜의 메모만 필터링되어야 하나 전체 메모가 그대로 노출되는 현상.
|
|
||||||
- **원인**:
|
|
||||||
1. 프론트엔드 API 호출 시 `date` 파라미터가 누락됨 (`static/js/api.js`).
|
|
||||||
2. 히트맵 컴포넌트에 클릭 이벤트 리스너가 구현되지 않음 (`static/js/components/HeatmapManager.js`).
|
|
||||||
3. 달력과 히트맵 간의 선택 상태 동기화 로직 부재.
|
|
||||||
|
|
||||||
## 조치 사항
|
|
||||||
1. **API 수정**: `static/js/api.js`의 `fetchMemos` 함수가 `filters.date`를 지원하도록 수정하고, `null` 값이 `"null"` 문자열로 전송되지 않도록 빈 문자열 처리 추가.
|
|
||||||
2. **백엔드 보완**: `app/routes/memo.py`에서 `date` 값이 `"null"` 또는 `"undefined"`로 들어올 경우 예외 처리 추가.
|
|
||||||
3. **히트맵 개선**:
|
|
||||||
- 각 날짜 셀에 클릭 이벤트 추가.
|
|
||||||
- `setSelectedDate` 메서드를 추가하여 외부에서 선택 상태를 주입할 수 있도록 함.
|
|
||||||
- 선택된 날짜에 대한 시각적 강조 스타일 추가 (`static/css/components/heatmap.css`).
|
|
||||||
4. **상태 동기화**: `AppService.js`의 `setFilter` 로직에서 날짜가 변경될 때 달력과 히트맵의 선택 상태를 동시에 업데이트하도록 수정.
|
|
||||||
|
|
||||||
## 향후 주의사항
|
|
||||||
- 필터링 기능을 추가하거나 수정할 때는 `AppService.state`와 실제 UI(달력, 히트맵, 사이드바 등) 간의 데이터 흐름이 일치하는지 확인해야 함.
|
|
||||||
- 새로운 API 요청 파라미터를 추가할 때는 `api.js` 모듈에서 해당 파라미터가 올바르게 인코딩되어 전달되는지 반드시 검증할 것.
|
|
||||||
+7
-6
@@ -1,4 +1,4 @@
|
|||||||
# 🧠 뇌사료 (Brain Dogfood) 프로젝트 문서화 (v5.0+)
|
# 🧠 뇌사료 (Brain Dogfood) 프로젝트 문서화 (v1.5)
|
||||||
|
|
||||||
> **"지식은 기록될 때 힘을 얻고, 연결될 때 생명을 얻는다."**
|
> **"지식은 기록될 때 힘을 얻고, 연결될 때 생명을 얻는다."**
|
||||||
|
|
||||||
@@ -12,10 +12,11 @@
|
|||||||
- **이중 보안**: 메모별 개별 암호화 및 미디어 보안 실드.
|
- **이중 보안**: 메모별 개별 암호화 및 미디어 보안 실드.
|
||||||
- **AI 구조화**: Gemini 2.0 Flash 기반 자동 요약 및 지능형 태깅.
|
- **AI 구조화**: Gemini 2.0 Flash 기반 자동 요약 및 지능형 태깅.
|
||||||
|
|
||||||
## ✨ What's New in v5.0
|
## ✨ What's New in v1.5
|
||||||
- **Heatmap**: 최근 1년/6개월/3개월/1개월 활동량 지표 추가.
|
- **Advanced Category Toggle**: 라이트 유저를 위해 카테고리 기능을 숨기거나 켤 수 있는 옵션 도입.
|
||||||
- **Performance**: 대량 데이터 로딩 최적화(Bulk Fetch) 및 무한 스크롤 도입.
|
- **i18n Stabilization**: 한국어/영어 전환 시 히트맵, 달력 등 동적 컴포넌트까지 완벽하게 자가 교정 및 번역 반영.
|
||||||
- **Editor**: 중요 지식 강조를 위한 글자 색상(Color Syntax) 기능 통합.
|
- **V5 Metadata Shield**: 정규식 엔진 고도화를 통해 시스템 메타데이터 중복 및 푸터 손상 원천 차단.
|
||||||
|
- **Data Integrity**: 다국어 환경의 정합성을 위해 내부 그룹명을 영문 상수로 통일.
|
||||||
|
|
||||||
## 📂 문서 인덱스
|
## 📂 문서 인덱스
|
||||||
1. [**사용자 매뉴얼 (User Manual)**](user_manual.md): **[최초 사용자 필독]** 사용법 및 연결 문법
|
1. [**사용자 매뉴얼 (User Manual)**](user_manual.md): **[최초 사용자 필독]** 사용법 및 연결 문법
|
||||||
@@ -27,4 +28,4 @@
|
|||||||
7. [**단축키 가이드 (Shortcuts Guide)**](shortcuts.md): **[업무 효율 극대화]** 탐색 및 편집 단축키 총정리
|
7. [**단축키 가이드 (Shortcuts Guide)**](shortcuts.md): **[업무 효율 극대화]** 탐색 및 편집 단축키 총정리
|
||||||
|
|
||||||
---
|
---
|
||||||
*Last Updated: 2026-04-15*
|
*Last Updated: 2026-04-16 (v1.5 Milestone Release)*
|
||||||
|
|||||||
+39
-19
@@ -1,4 +1,4 @@
|
|||||||
# 📡 데이터베이스 및 API 명세서 (v13.5)
|
# 📡 데이터베이스 및 API 명세서 (v1.5)
|
||||||
|
|
||||||
본 문서는 `뇌사료` 프로젝트의 데이터 저장 구조(Schema)와 모든 외부 통신 인터페이스(API)를 상세히 기술합니다.
|
본 문서는 `뇌사료` 프로젝트의 데이터 저장 구조(Schema)와 모든 외부 통신 인터페이스(API)를 상세히 기술합니다.
|
||||||
|
|
||||||
@@ -10,10 +10,36 @@
|
|||||||
| :--- | :--- | :--- | :--- |
|
| :--- | :--- | :--- | :--- |
|
||||||
| `id` | INTEGER | PRIMARY KEY | 자동 증가 고유 아이디 |
|
| `id` | INTEGER | PRIMARY KEY | 자동 증가 고유 아이디 |
|
||||||
| `title` | TEXT | - | 메모 제목 |
|
| `title` | TEXT | - | 메모 제목 |
|
||||||
| `content` | TEXT | - | 메모 본문 (암호화 시 바이너리 텍스트) |
|
| `content` | TEXT | - | 메모 본문 (마크다운) |
|
||||||
|
| `summary` | TEXT | - | AI 생성 요약문 |
|
||||||
|
| `color` | TEXT | `#2c3e50` | 메모 카드 테마 색상 |
|
||||||
|
| `is_pinned` | BOOLEAN | 0 | 상단 고정 여부 |
|
||||||
|
| `status` | TEXT | `'active'` | 상태 (`active`, `done`, `archived`) |
|
||||||
|
| `group_name` | TEXT | `'default'` | 그룹 ID (영문 상수 권장) |
|
||||||
|
| `category` | TEXT | - | (v1.5) 소속 카테고리 명 |
|
||||||
| `is_encrypted` | BOOLEAN | 0 | 암호화 여부 |
|
| `is_encrypted` | BOOLEAN | 0 | 암호화 여부 |
|
||||||
|
| `created_at` | TIMESTAMP | - | 생성 일시 |
|
||||||
|
| `updated_at` | TIMESTAMP | - | 수정 일시 |
|
||||||
|
|
||||||
### 1.2 `memo_links` 테이블 (v7.0 추가)
|
### 1.2 `tags` 테이블
|
||||||
|
메모와 태그 간의 관계를 저장합니다.
|
||||||
|
| 컬럼명 | 타입 | 설명 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `memo_id` | INTEGER | 소속 메모 ID |
|
||||||
|
| `name` | TEXT | 태그 이름 |
|
||||||
|
| `source` | TEXT | 생성 주체 (`user`, `ai`) |
|
||||||
|
|
||||||
|
### 1.3 `attachments` 테이블
|
||||||
|
메모에 첨부된 미디어 자산을 관리합니다.
|
||||||
|
| 컬럼명 | 타입 | 설명 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `memo_id` | INTEGER | 소속 메모 ID |
|
||||||
|
| `filename` | TEXT | 저장된 파일명 (UUID 기반) |
|
||||||
|
| `original_name`| TEXT | 원본 파일명 |
|
||||||
|
| `file_type` | TEXT | MIME 타입 |
|
||||||
|
| `size` | INTEGER | 파일 크기 (Bytes) |
|
||||||
|
|
||||||
|
### 1.4 `memo_links` 테이블
|
||||||
메모 간의 `[[#ID]]` 링크 및 시각화 인력을 관리합니다.
|
메모 간의 `[[#ID]]` 링크 및 시각화 인력을 관리합니다.
|
||||||
| 컬럼명 | 타입 | 설명 |
|
| 컬럼명 | 타입 | 설명 |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
@@ -22,23 +48,17 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌐 2. API 엔드포인트 전수 명세
|
## 🌐 2. API 엔드포인트 명세 (주요 항목)
|
||||||
|
|
||||||
### 2.1 Memos & Analysis
|
### 2.1 Memos & Search
|
||||||
| Method | URL | Description |
|
- `GET /api/memos`: 필터링된 메모 목록 및 메타데이터 통합 조회.
|
||||||
| :--- | :--- | :--- |
|
- `POST /api/memos/<id>/decrypt`: 암호화된 메모 복호화 요청.
|
||||||
| `GET` | `/api/memos` | 전체 메모 목록, 태그, 첨부파일, **백링크** 정보 통합 조회 |
|
- `GET /api/stats/heatmap`: 히트맵 렌더링을 위한 통계 데이터 조회.
|
||||||
| `POST` | `/api/memos/<id>/decrypt` | 비밀번호 검증 및 본문 일시 복호화 |
|
|
||||||
| `GET` | `/api/stats/heatmap` | 최근 N일간의 일자별 메모 작성 수(통계) 조회 (`days` 파라미터 지원) |
|
|
||||||
|
|
||||||
### 2.2 Assets (제한적 접근)
|
### 2.2 Settings & Configuration (v1.5 업데이트)
|
||||||
| Method | URL | Security Policy | Description |
|
| Method | URL | Parameters | Description |
|
||||||
| :--- | :--- | :--- | :--- |
|
| :--- | :--- | :--- | :--- |
|
||||||
| `GET` | `/api/download/<filename>` | **세션 필수(로그인 상호작용)** | 이미지/파일 다운로드. 이미지인 경우 `inline` 처리 및 암호화 메모 관련 파일은 로그인 미달 시 403 차단. |
|
| `GET` | `/api/settings` | - | 테마, 언어, 고급 기능 활성화 상태 조회 |
|
||||||
| `POST` | `/api/upload` | `login_required` | 파일 업로드 및 서버 측 마스터 키 암호화 저장. |
|
| `POST` | `/api/settings` | `lang`, `enable_categories`, `bg_color` 등 | 서버 설정을 영구 업데이트 |
|
||||||
|
|
||||||
### 2.3 Settings & Ops (v11.0 추가)
|
> **v1.5 변경점**: `lang`(언어), `enable_categories`(고급 카테고리 사용 여부) 필드가 추가되었습니다.
|
||||||
| Method | URL | Description |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `GET` | `/api/settings` | 서버 사이드 테마 및 전역 설정 조회 |
|
|
||||||
| `POST` | `/api/settings` | UI 테마 설정을 서버에 영구 기록 |
|
|
||||||
|
|||||||
+13
-15
@@ -1,4 +1,4 @@
|
|||||||
# 🏢 시스템 아키텍처 및 폴더 구조 (v5.0+)
|
# 🏢 시스템 아키텍처 및 폴더 구조 (v1.5)
|
||||||
|
|
||||||
본 문서는 `뇌사료` 프로젝트의 물리적 파일 구조와 논리적 설계 아키텍처를 상세히 기술합니다.
|
본 문서는 `뇌사료` 프로젝트의 물리적 파일 구조와 논리적 설계 아키텍처를 상세히 기술합니다.
|
||||||
|
|
||||||
@@ -11,11 +11,9 @@
|
|||||||
| `/data` | **Database Box** | SQLite3 DB 파일 (`memos.db`) 저장 위치 |
|
| `/data` | **Database Box** | SQLite3 DB 파일 (`memos.db`) 저장 위치 |
|
||||||
| `/docs` | **Documentation** | 시스템 기술 문서 및 가이드 |
|
| `/docs` | **Documentation** | 시스템 기술 문서 및 가이드 |
|
||||||
| `/logs` | **Log Box** | 시스템 작동 및 접근 로그 (`app.log`) |
|
| `/logs` | **Log Box** | 시스템 작동 및 접근 로그 (`app.log`) |
|
||||||
| `/static` | **Static Assets** | CSS, 이미지, 파비코 및 프론트엔드 JS |
|
| `/static` | **Static Assets** | CSS, 이미지 및 프론트엔드 리소스 |
|
||||||
| `/static/js/components` | **UI Components** | D3.js 시각화 모듈 및 UI 핵심 로직 |
|
| `/static/js/components` | **UI Components** | D3.js 시각화 모듈 및 UI 핵심 로직 |
|
||||||
| `/templates` | **HTML Templates** | Jinja2 기반 레이아웃 및 페이지 |
|
| `/templates` | **HTML Templates** | Jinja2 기반 레이아웃 및 페이지 |
|
||||||
| `deploy.py` | **Ops Tool** | 수술적 정밀 배포 도구 (Surgical Deployment) |
|
|
||||||
| `backup.py` | **Disaster Recovery** | 핵심 데이터(DB, .env, 첨부파일) 증분 백업 도구 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -23,16 +21,16 @@
|
|||||||
|
|
||||||
### 2.1 Backend: Blueprint-based Modular Flask
|
### 2.1 Backend: Blueprint-based Modular Flask
|
||||||
- **패키지 구조**: `app/__init__.py`에서 중앙 집중식으로 앱을 생성하고, `routes/` 아래의 각 기능을 Blueprint로 등록합니다.
|
- **패키지 구조**: `app/__init__.py`에서 중앙 집중식으로 앱을 생성하고, `routes/` 아래의 각 기능을 Blueprint로 등록합니다.
|
||||||
- **보안 실드 (Security Shield)**: `before_request` 단계에서 비정상적인 트래픽 및 파라미터를 필터링하는 로깅 시스템이 선제적으로 작동합니다.
|
- **다국어 엔진 (v1.5)**: 서버 사이드에서도 `i18n.py`를 통해 클라이언트 언어 환경에 맞춤화된 응답(에러 메시지 등)을 제공합니다.
|
||||||
- **성능 최적화 (Bulk Fetch)**: 다량의 메모리 조회 시 발생하는 N+1 문제를 방지하기 위해 태그, 첨부파일, 백링크 정보를 한꺼번에 Fetch하는 벌크 조회 로직이 적용되었습니다.
|
|
||||||
|
|
||||||
### 2.2 Frontend: Modular Component Architecture
|
### 2.2 Frontend: State-Driven UI
|
||||||
- **지식 네뷸라 (Knowledge Nebula)**: D3.js의 물리 시뮬레이션 엔진을 도입하여 유기적인 성단 구조를 시각화합니다.
|
- **컴포넌트 중심 설계**: `HeatmapManager.js`, `CalendarManager.js`, `ComposerCategoryUI.js` 등으로 독립된 모듈 구조를 채택했습니다.
|
||||||
- **컴포넌트 중심 설계**: `HeatmapManager.js` (활동 시각화), `CalendarManager.js` (달력), `Visualizer.js` (그래프), `DrawerManager.js` (탐색기) 등으로 독립된 모듈 구조를 채택하여 유지보수성을 극대화했습니다.
|
- **State Management**: `AppService.js`를 통해 전역 상태를 관리하며, 설정 변경(언어, 테마) 시 `ThemeManager.js`가 시스템 전반의 정합성을 동기화합니다.
|
||||||
- **레이아웃 혁명**: **무한 스크롤(Infinite Scroll)** 페이징 기법을 도입하여 수천 개의 지식 파편도 성능 저하 없이 탐색할 수 있습니다.
|
|
||||||
- **State Management**: `AppService.js`를 중앙 상태 관리 엔진으로 활용하여 데이터 요청과 UI 업데이트의 정합성을 유지합니다.
|
|
||||||
|
|
||||||
### 2.3 Ops & Reliability
|
### 2.3 Data Policy: English Constant Policy (v1.5 정책)
|
||||||
- **Merged Configuration**: 개발/운영 환경의 환경변수를 한곳에서 관리하며, 배포 시 `.env` 파일을 통해 보안 설정이 주입됩니다.
|
- **데이터 정합성**: 다국어 환경에서 그룹 필터링 등이 오작동하는 것을 방지하기 위해, 데이터베이스의 `group_name` 필드에는 **영문 상수**(`default`, `files`, `done` 등)를 저장하는 것을 원칙으로 합니다.
|
||||||
- **Surgical Cleanup**: 배포 시 운영 데이터(DB, Uploads)는 보존하고 코드 영역만 정밀하게 교체하는 수술적 배포 방식을 채택했습니다.
|
- **매핑 방식**: 화면에 노출되는 텍스트는 프론트엔드 i18n 매니저를 통해 사용자의 현재 언어 설정에 맞춰 동적으로 번역되어 표기됩니다.
|
||||||
- **Disaster Recovery**: `backup.py`를 통해 서버 침해나 시스템 붕괴 시에도 3대 핵심 자산(.env, DB, Uploads)만으로 즉시 복구가 가능한 구조를 갖췄습니다.
|
|
||||||
|
### 2.4 Ops & Reliability
|
||||||
|
- **Surgical Cleanup**: 배포 시 운영 데이터(DB, Uploads)는 보존하고 코드 영역만 정밀하게 교체하는 방식을 채택했습니다.
|
||||||
|
- **Disaster Recovery**: `backup.py`를 통해 핵심 자산(.env, DB, Uploads)을 증분 백업하여 언제든 즉시 복구가 가능합니다.
|
||||||
|
|||||||
+15
-20
@@ -1,6 +1,6 @@
|
|||||||
# 💎 핵심 기능 가이드 (v13.3)
|
# 💎 핵심 기능 가이드 (v1.5)
|
||||||
|
|
||||||
본 문서는 `뇌사료` 프로젝트를 상징하는 핵심 기능들인 **지식 시각화**, **암호화**, **AI 분석**에 대한 상세 명세를 담고 있습니다.
|
본 문서는 `뇌사료` 프로젝트를 상징하는 핵심 기능들인 **지식 시각화**, **암호화**, **AI 분석** 및 **고객 맞춤형 설정**에 대한 상세 명세를 담고 있습니다.
|
||||||
|
|
||||||
## 🌌 1. 지식 네뷸라 (Knowledge Nebula)
|
## 🌌 1. 지식 네뷸라 (Knowledge Nebula)
|
||||||
D3.js v7 물리 시뮬레이션 엔진을 통해 파편화된 메모들을 유기적인 우주 성단 구조로 시각화합니다.
|
D3.js v7 물리 시뮬레이션 엔진을 통해 파편화된 메모들을 유기적인 우주 성단 구조로 시각화합니다.
|
||||||
@@ -16,7 +16,7 @@ D3.js v7 물리 시뮬레이션 엔진을 통해 파편화된 메모들을 유
|
|||||||
|
|
||||||
### 2.1 메모 및 파일 보안
|
### 2.1 메모 및 파일 보안
|
||||||
- **개별 암호화**: 메모마다 고유한 비밀번호를 사용하여 `Fernet (AES-128 CBC/HMAC)` 방식으로 본문을 암호화합니다.
|
- **개별 암호화**: 메모마다 고유한 비밀번호를 사용하여 `Fernet (AES-128 CBC/HMAC)` 방식으로 본문을 암호화합니다.
|
||||||
- **미디어 실드 (v10.1)**: 모든 첨부파일은 서버 마스터 키로 암호화되어 저장됩니다. 암호화된 메모의 이미지는 **로그인된 세션**에서만 정밀하게 렌더링을 허용하여 데이터 유출을 원천 차단합니다.
|
- **미디어 실드**: 모든 첨부파일은 서버 마스터 키로 암호화되어 저장됩니다. 암호화된 메모의 이미지는 **로그인된 세션**에서만 정밀하게 렌더링을 허용하여 데이터 유출을 원천 차단합니다.
|
||||||
|
|
||||||
## 🧠 3. Gemini AI 기반 지식 구조화 (AI Insight)
|
## 🧠 3. Gemini AI 기반 지식 구조화 (AI Insight)
|
||||||
|
|
||||||
@@ -29,20 +29,15 @@ D3.js v7 물리 시뮬레이션 엔진을 통해 파편화된 메모들을 유
|
|||||||
### 4.1 연결 문법 (`[[#ID]]`)
|
### 4.1 연결 문법 (`[[#ID]]`)
|
||||||
- **자동 링크**: 본문에 `[[#12]]`와 같이 입력하면 뷰어에서 클릭 가능한 링크로 변환되며, 지식 맵 상에서 두 노드 사이에 **강력한 실선**이 형성됩니다.
|
- **자동 링크**: 본문에 `[[#12]]`와 같이 입력하면 뷰어에서 클릭 가능한 링크로 변환되며, 지식 맵 상에서 두 노드 사이에 **강력한 실선**이 형성됩니다.
|
||||||
- **역방향 추적 (Backlinks)**: 특정 메모 카드 하단에 해당 메모를 인용 중인 다른 메모의 목록이 노출되어, 지식의 흐름을 양방향으로 추적할 수 있습니다.
|
- **역방향 추적 (Backlinks)**: 특정 메모 카드 하단에 해당 메모를 인용 중인 다른 메모의 목록이 노출되어, 지식의 흐름을 양방향으로 추적할 수 있습니다.
|
||||||
27:
|
|
||||||
32: ## 🌡️ 5. 지식 성장 히트맵 (Intellectual Growth Heatmap) - v14.0
|
## 🌡️ 5. 지식 성장 히트맵 (Intellectual Growth Heatmap)
|
||||||
33:
|
- **활동 시각화**: 최근 365일간의 활동량을 GitHub 스타일의 그리드로 시각화하여 지식 축적의 꾸준함을 독려합니다.
|
||||||
34: ### 5.1 활동 시각화
|
- **동적 범위 필터링**: 사용자의 필요에 따라 **1개월 / 3개월 / 6개월 / 1년** 단위를 자유롭게 선택하여 볼 수 있습니다.
|
||||||
35: - **기록 습관 형성**: 최근 365일간의 활동량을 GitHub 스타일의 그리드로 시각화하여 지식 축적의 꾸준함을 독려합니다.
|
|
||||||
36: - **동적 범위 필터링**: 사용자의 필요에 따라 **1개월 / 3개월 / 6개월 / 1년** 단위를 자유롭게 선택하여 볼 수 있습니다.
|
## 🎨 6. 확장된 에디터 스타일링 (Enhanced Editor)
|
||||||
37: - **상태 보존**: 선택한 보기 설정은 `localStorage`에 저장되어 재접속 시에도 유지됩니다.
|
- **컬러 텍스트 (Color Syntax)**: Toast UI Editor의 컬러 신택스 플러그인을 통합하여, 중요 키워드를 다양한 색상으로 강조할 수 있습니다.
|
||||||
38:
|
- **V5 메타데이터 쉴드**: 정밀한 정규식 엔진을 도입하여 메모 하단의 시스템 메타데이터가 중복되거나 파손되는 것을 방지하고 항상 깔끔한 상태를 유지합니다.
|
||||||
39: ### 5.2 지능형 히트맵 알고리즘
|
|
||||||
40: - **단계별 농도**: 해당 일의 메모 작성 수에 따라 5단계(`lvl-0`~`lvl-4`)의 색상 농도가 적용됩니다.
|
## ⚙️ 7. 맞춤형 사용자 환경 (v1.5 신규)
|
||||||
41: - **프리미엄 그라데이션**: 뇌사료 특유의 Cyan(시안)에서 Purple(보라)로 이어지는 네온 그라데이션 테마를 따릅니다.
|
- **고급 설정 (Advanced Categories)**: 라이트 유저를 위해 복잡한 카테고리 기능을 숨길 수 있습니다. 설정에서 활성화 시에만 작성기 칩과 사이드바 섹션이 정밀한 레이아웃으로 노출됩니다.
|
||||||
42:
|
- **글로벌 인텔리전스 (i18n Stabilization)**: 한국어와 영어를 완벽하게 지원하며, 언어 설정을 변경할 경우 히트맵과 달력 등 동적 컴포넌트까지 실시간으로(자동 새로고침) 완벽하게 번역이 적용됩니다.
|
||||||
43: ## 🎨 6. 확장된 에디터 스타일링 (Enhanced Editor)
|
|
||||||
44:
|
|
||||||
45: ### 6.1 컬러 텍스트 (Color Syntax)
|
|
||||||
46: - **시각적 강조**: Toast UI Editor의 컬러 신택스 플러그인을 통합하여, 본문 중 중요한 지식 키워드를 다양한 색상으로 강조할 수 있습니다.
|
|
||||||
47: - **지각적 설계**: 다크 모드 환경에서도 가독성이 뛰어난 색상 팔레트를 우선적으로 제공합니다.
|
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
# 뇌사료 ↔ 옵시디언 플러그인 연동 구현 계획
|
||||||
|
|
||||||
|
> 작성일: 2026-04-16
|
||||||
|
> 작업 경로: `c:\project\my_util\memo_server`
|
||||||
|
> 서버 주소: `your-server-ip:5093`
|
||||||
|
> 원격 경로: `/home/your-username/Script/memo_server`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 제품 컨셉 (확정)
|
||||||
|
|
||||||
|
```
|
||||||
|
뇌사료 단독: 웹 기반 빠른 캡처 메모 + AI 태깅/요약
|
||||||
|
뇌사료 + 옵시디언: 뇌사료(INPUT/캡처) → 옵시디언(PROCESS/정리/아카이브/그래프)
|
||||||
|
```
|
||||||
|
|
||||||
|
**포지셔닝 핵심:**
|
||||||
|
- 뇌사료는 옵시디언의 "웹 프론트엔드 + AI 레이어" 역할
|
||||||
|
- 옵시디언 사용자의 고질적 불편(로컬 파일 기반 → 모바일/웹 접근 어려움)을 해결
|
||||||
|
- 뇌사료 → 옵시디언은 **단방향 동기화** (뇌사료가 Single Source of Truth)
|
||||||
|
- 옵시디언은 읽기/정리 전용 뷰로 사용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 현재 아키텍처 현황
|
||||||
|
|
||||||
|
### 2-1. 서버 구조 (Flask + Blueprint)
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── __init__.py # create_app(), Blueprint 등록, 보안 미들웨어
|
||||||
|
├── constants.py # GROUP_DEFAULT="default", GROUP_FILES="files", GROUP_DONE="done"
|
||||||
|
├── database.py # SQLite, DB_PATH=data/memos.db
|
||||||
|
├── auth.py # @login_required 데코레이터 (세션 기반)
|
||||||
|
├── security.py # Fernet 암호화/복호화 (PBKDF2 + ENCRYPTION_SEED)
|
||||||
|
├── ai.py # Gemini AI 태깅/요약
|
||||||
|
└── routes/
|
||||||
|
├── __init__.py # register_blueprints(app) ← 여기에 새 Blueprint 추가
|
||||||
|
├── memo.py # /api/memos CRUD
|
||||||
|
├── file.py # /api/files 업로드/다운로드
|
||||||
|
├── ai.py # /api/ai
|
||||||
|
├── auth.py # /login /logout
|
||||||
|
├── settings.py # /api/settings
|
||||||
|
└── main.py # / 메인 페이지
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2-2. DB 스키마 (SQLite: data/memos.db)
|
||||||
|
```sql
|
||||||
|
memos(
|
||||||
|
id, title, content, summary, color,
|
||||||
|
is_pinned, status, -- status: 'active' | 'done'
|
||||||
|
group_name, -- 'default' | 'files' | 'done' | 사용자 정의
|
||||||
|
is_encrypted, -- 0 | 1
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
tags(id, memo_id, name, source) -- source: 'user' | 'ai'
|
||||||
|
attachments(id, memo_id, filename, original_name, file_type, size, created_at)
|
||||||
|
memo_links(id, source_id, target_id) -- 백링크/전방링크
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2-3. 현재 인증 방식
|
||||||
|
- **세션 기반** (`session['logged_in']`) — 브라우저 전용
|
||||||
|
- API Token 인증 **없음** → Phase 1에서 추가 필요
|
||||||
|
|
||||||
|
### 2-4. 암호화 방식
|
||||||
|
```python
|
||||||
|
# app/security.py
|
||||||
|
# PBKDF2(password + ENCRYPTION_SEED) → Fernet 키 → 본문 암호화
|
||||||
|
# .env의 ENCRYPTION_SEED 필수
|
||||||
|
encrypt_content(content, password)
|
||||||
|
decrypt_content(encrypted_data, password) # 실패 시 None 반환
|
||||||
|
```
|
||||||
|
암호화 메모는 `is_encrypted=1`, 복호화는 `/api/memos/{id}/decrypt` POST로 처리.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 구현 계획 (3단계)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1. API Token 인증 추가 (뇌사료 서버)
|
||||||
|
|
||||||
|
**목적:** 세션 없이 외부에서 API를 호출할 수 있도록 Token 인증 추가
|
||||||
|
**원칙:** 기존 `@login_required` 데코레이터를 건드리지 않고 새 데코레이터 추가
|
||||||
|
|
||||||
|
#### 1-1. `.env`에 토큰 추가
|
||||||
|
```env
|
||||||
|
# .env (기존 항목 유지, 아래 추가)
|
||||||
|
OBSIDIAN_API_TOKEN=your_secret_token_here
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1-2. `app/auth.py`에 토큰 인증 데코레이터 추가
|
||||||
|
```python
|
||||||
|
def token_required(view):
|
||||||
|
"""API Token 기반 인증 데코레이터 (Obsidian 플러그인 전용)"""
|
||||||
|
@functools.wraps(view)
|
||||||
|
def wrapped_view(**kwargs):
|
||||||
|
token = request.headers.get('X-API-Token') or request.args.get('token')
|
||||||
|
expected = os.getenv('OBSIDIAN_API_TOKEN', '')
|
||||||
|
if not expected or token != expected:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 401
|
||||||
|
return view(**kwargs)
|
||||||
|
return wrapped_view
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1-3. 새 Blueprint 파일 생성: `app/routes/sync.py`
|
||||||
|
```python
|
||||||
|
# app/routes/sync.py
|
||||||
|
# 옵시디언 동기화 전용 Blueprint
|
||||||
|
|
||||||
|
sync_bp = Blueprint('sync', __name__)
|
||||||
|
|
||||||
|
# GET /api/sync/export
|
||||||
|
# 전체 메모 목록 반환 (암호화 메모는 플레이스홀더 처리)
|
||||||
|
# 쿼리파라미터: since=2024-01-01T00:00:00 (증분 동기화용)
|
||||||
|
|
||||||
|
# POST /api/sync/decrypt/<id>
|
||||||
|
# 단일 암호화 메모 복호화 반환
|
||||||
|
# 헤더: X-Memo-Password: 비밀번호
|
||||||
|
|
||||||
|
# GET /api/sync/groups
|
||||||
|
# 그룹 목록 반환
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1-4. `app/routes/__init__.py`에 Blueprint 등록
|
||||||
|
```python
|
||||||
|
from .sync import sync_bp
|
||||||
|
app.register_blueprint(sync_bp)
|
||||||
|
```
|
||||||
|
|
||||||
|
**완료 기준:** `curl -H "X-API-Token: xxx" http://서버/api/sync/export` 가 JSON 반환
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2. Python 동기화 스크립트 (로컬 실행)
|
||||||
|
|
||||||
|
**목적:** 뇌사료 API를 호출해서 옵시디언 Vault에 .md 파일로 저장
|
||||||
|
**실행 방식:** Windows Task Scheduler 또는 cron으로 주기 실행 (5~10분)
|
||||||
|
**위치:** 프로젝트 루트의 `obsidian_sync/` 폴더
|
||||||
|
|
||||||
|
#### 2-1. 파일 구조
|
||||||
|
```
|
||||||
|
obsidian_sync/
|
||||||
|
├── obsidian_sync.py # 메인 동기화 스크립트
|
||||||
|
├── config.json # 설정 파일
|
||||||
|
└── last_sync.txt # 마지막 동기화 시각 저장 (증분 동기화용)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2-2. `obsidian_sync/config.json`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server_url": "http://your-server-ip:5093",
|
||||||
|
"api_token": "your_secret_token_here",
|
||||||
|
"vault_path": "C:/Users/your-username/Documents/ObsidianVault/뇌사료",
|
||||||
|
"sync_interval_minutes": 10,
|
||||||
|
"encrypted_memo_handling": "placeholder",
|
||||||
|
"frontmatter": true,
|
||||||
|
"group_to_folder": {
|
||||||
|
"default": "inbox",
|
||||||
|
"files": "files",
|
||||||
|
"done": "archive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2-3. 변환 규칙 (뇌사료 → .md)
|
||||||
|
|
||||||
|
| 뇌사료 필드 | 옵시디언 변환 |
|
||||||
|
|---|---|
|
||||||
|
| `title` | 파일명 + H1 헤더 |
|
||||||
|
| `content` | 본문 (HTML → Markdown 변환) |
|
||||||
|
| `tags` | frontmatter `tags:` + 본문 `#태그` |
|
||||||
|
| `group_name` | 하위 폴더 분류 |
|
||||||
|
| `backlinks` | 본문 하단 `[[링크제목]]` |
|
||||||
|
| `is_encrypted=1` | `[🔒 암호화된 메모 — 뇌사료에서 확인]` |
|
||||||
|
| `created_at` | frontmatter `date:` |
|
||||||
|
| `updated_at` | frontmatter `updated:` |
|
||||||
|
| `summary` | frontmatter `summary:` |
|
||||||
|
|
||||||
|
#### 2-4. 생성될 .md 예시
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
id: 42
|
||||||
|
date: 2026-04-15
|
||||||
|
updated: 2026-04-16
|
||||||
|
tags: [python, flask, ai]
|
||||||
|
source: 뇌사료
|
||||||
|
group: default
|
||||||
|
---
|
||||||
|
|
||||||
|
# 메모 제목
|
||||||
|
|
||||||
|
본문 내용...
|
||||||
|
|
||||||
|
---
|
||||||
|
**Tags:** #python #flask #ai
|
||||||
|
**Links:** [[관련 메모 제목]]
|
||||||
|
**Source:** [뇌사료에서 열기](http://your-server-ip:5093)
|
||||||
|
```
|
||||||
|
|
||||||
|
**완료 기준:** 스크립트 실행 후 Vault 폴더에 .md 파일 생성 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3. TypeScript 옵시디언 플러그인 (선택적 고도화)
|
||||||
|
|
||||||
|
**목적:** Phase 2가 안정화된 후, 옵시디언 내에서 직접 UI 제공
|
||||||
|
**전제조건:** Phase 1, 2 완료 후 진행
|
||||||
|
**개발 언어:** TypeScript (옵시디언 플러그인 필수)
|
||||||
|
|
||||||
|
#### 3-1. 플러그인 저장소 구조
|
||||||
|
```
|
||||||
|
obsidian-brainsryo-plugin/ # 별도 Git 저장소 권장
|
||||||
|
├── src/
|
||||||
|
│ ├── main.ts # 플러그인 진입점
|
||||||
|
│ ├── api.ts # 뇌사료 API 클라이언트
|
||||||
|
│ ├── converter.ts # 뇌사료 JSON → Obsidian .md 변환
|
||||||
|
│ ├── settings.ts # 플러그인 설정 UI
|
||||||
|
│ └── modal.ts # 암호화 메모 비밀번호 입력 모달
|
||||||
|
├── manifest.json
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3-2. 플러그인 설정 항목 (UI)
|
||||||
|
```
|
||||||
|
뇌사료 서버 URL: [http://your-server-ip:5093]
|
||||||
|
API Token: [***************]
|
||||||
|
저장 폴더: [뇌사료/]
|
||||||
|
동기화 주기: [10분]
|
||||||
|
암호화 메모 처리: [플레이스홀더 ▼] ← 또는 "비밀번호 입력"
|
||||||
|
자동 동기화: [ON/OFF]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3-3. 암호화 메모 처리 흐름 (플러그인 버전)
|
||||||
|
```
|
||||||
|
옵시디언에서 암호화 메모 클릭
|
||||||
|
→ 비밀번호 입력 모달 표시 (Obsidian Modal API)
|
||||||
|
→ POST /api/sync/decrypt/{id} (헤더: X-Memo-Password)
|
||||||
|
→ 복호화 성공 시 임시 .md 생성 후 표시
|
||||||
|
→ 닫으면 임시 파일 삭제 (디스크에 평문 저장 안 함)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3-4. 개발 우선순위
|
||||||
|
1. 단방향 동기화 (뇌사료 → 옵시디언) 기본 버전
|
||||||
|
2. 설정 UI
|
||||||
|
3. 증분 동기화 (마지막 동기화 이후 변경분만)
|
||||||
|
4. 암호화 메모 지원 (비밀번호 모달)
|
||||||
|
5. (미래) 옵시디언 → 뇌사료 import 기능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 구현 순서 체크리스트
|
||||||
|
|
||||||
|
### Phase 1 — API Token (뇌사료 서버 수정)
|
||||||
|
- [ ] `.env`에 `OBSIDIAN_API_TOKEN` 추가
|
||||||
|
- [ ] `.env.example`에도 항목 추가 (값 없이)
|
||||||
|
- [ ] `app/auth.py`에 `token_required` 데코레이터 추가
|
||||||
|
- [ ] `app/routes/sync.py` 파일 생성
|
||||||
|
- [ ] `GET /api/sync/export` 엔드포인트 (`since` 파라미터 지원)
|
||||||
|
- [ ] `POST /api/sync/decrypt/<id>` 엔드포인트
|
||||||
|
- [ ] `GET /api/sync/groups` 엔드포인트
|
||||||
|
- [ ] `app/routes/__init__.py`에 `sync_bp` 등록
|
||||||
|
- [ ] 서버 배포 (`python deploy.py` — 사용자 승인 필수)
|
||||||
|
|
||||||
|
### Phase 2 — Python 동기화 스크립트
|
||||||
|
- [ ] `obsidian_sync/` 폴더 생성
|
||||||
|
- [ ] `obsidian_sync/config.json` 작성
|
||||||
|
- [ ] `obsidian_sync/obsidian_sync.py` 작성
|
||||||
|
- [ ] API 호출 (Token 인증)
|
||||||
|
- [ ] HTML → Markdown 변환 (`markdownify` 라이브러리)
|
||||||
|
- [ ] frontmatter 생성
|
||||||
|
- [ ] 태그/백링크 변환
|
||||||
|
- [ ] 그룹 → 폴더 분류
|
||||||
|
- [ ] 증분 동기화 (`last_sync.txt`)
|
||||||
|
- [ ] 암호화 메모 플레이스홀더 처리
|
||||||
|
- [ ] 스크립트 테스트 (실제 Vault에 파일 생성 확인)
|
||||||
|
- [ ] Windows Task Scheduler 등록
|
||||||
|
|
||||||
|
### Phase 3 — TypeScript 옵시디언 플러그인 (나중에)
|
||||||
|
- [ ] Node.js 개발 환경 세팅
|
||||||
|
- [ ] obsidian-sample-plugin 템플릿 클론
|
||||||
|
- [ ] API 클라이언트 구현
|
||||||
|
- [ ] 설정 UI 구현
|
||||||
|
- [ ] 동기화 로직 구현
|
||||||
|
- [ ] 암호화 메모 모달 구현
|
||||||
|
- [ ] 로컬 설치 테스트 (`.obsidian/plugins/` 복사)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API 스펙 (Phase 1에서 구현할 엔드포인트)
|
||||||
|
|
||||||
|
### `GET /api/sync/export`
|
||||||
|
```
|
||||||
|
Headers: X-API-Token: {OBSIDIAN_API_TOKEN}
|
||||||
|
Params:
|
||||||
|
- since (optional): ISO 8601 datetime, 이 시각 이후 수정된 메모만 반환
|
||||||
|
- limit (optional): 기본 1000
|
||||||
|
Response:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"title": "메모 제목",
|
||||||
|
"content": "<p>HTML 본문</p>",
|
||||||
|
"summary": "AI 요약",
|
||||||
|
"tags": [{"name": "python", "source": "ai"}, ...],
|
||||||
|
"group_name": "default",
|
||||||
|
"is_encrypted": false,
|
||||||
|
"is_pinned": false,
|
||||||
|
"backlinks": [{"source_id": 10, "title": "다른 메모"}],
|
||||||
|
"links": [{"target_id": 20, "title": "링크된 메모"}],
|
||||||
|
"created_at": "2026-04-15T10:00:00",
|
||||||
|
"updated_at": "2026-04-16T08:00:00"
|
||||||
|
},
|
||||||
|
// is_encrypted=true인 경우:
|
||||||
|
{
|
||||||
|
"id": 99,
|
||||||
|
"title": "암호화된 메모",
|
||||||
|
"content": null,
|
||||||
|
"is_encrypted": true,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/sync/decrypt/<id>`
|
||||||
|
```
|
||||||
|
Headers:
|
||||||
|
X-API-Token: {OBSIDIAN_API_TOKEN}
|
||||||
|
X-Memo-Password: {메모_비밀번호}
|
||||||
|
Response (성공): {"content": "복호화된 본문"}
|
||||||
|
Response (실패): {"error": "Invalid password"}, 403
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/sync/groups`
|
||||||
|
```
|
||||||
|
Headers: X-API-Token: {OBSIDIAN_API_TOKEN}
|
||||||
|
Response: {"groups": ["default", "files", "done", "custom_group1", ...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 주의사항 및 설계 원칙
|
||||||
|
|
||||||
|
> **단방향 원칙:** 뇌사료 → 옵시디언 방향만 동기화.
|
||||||
|
> 옵시디언에서 .md를 수정해도 뇌사료 DB에는 반영되지 않음.
|
||||||
|
> 양방향 동기화는 충돌 처리 복잡도가 높아 Phase 3 이후 별도 검토.
|
||||||
|
|
||||||
|
> **암호화 메모 보안:**
|
||||||
|
> 복호화된 본문은 절대 디스크에 저장하지 않음.
|
||||||
|
> Phase 2 스크립트는 기본적으로 `encrypted_memo_handling: "placeholder"` 유지.
|
||||||
|
|
||||||
|
> **파일명 규칙:** 옵시디언 파일명 특수문자 금지 (`/ \ : * ? " < > |`)
|
||||||
|
> 뇌사료 제목의 해당 문자는 `_`로 치환. 중복 시 `제목_id42.md` 형식.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 작업 이어받기 안내 (다른 AI 인스턴스용)
|
||||||
|
|
||||||
|
1. **먼저 읽을 파일들:**
|
||||||
|
- `app/auth.py` — 현재 인증 구조 확인
|
||||||
|
- `app/routes/__init__.py` — Blueprint 등록 방식 확인
|
||||||
|
- `app/routes/memo.py` — 기존 API 패턴 참고
|
||||||
|
- `app/security.py` — 암호화/복호화 함수 확인
|
||||||
|
- `.env` — `OBSIDIAN_API_TOKEN` 추가 여부 확인
|
||||||
|
|
||||||
|
2. **시작점:** Phase 1 체크리스트 항목 순서대로 진행
|
||||||
|
|
||||||
|
3. **배포 방법:** 수정 완료 후 `python deploy.py` 실행
|
||||||
|
(반드시 사용자 승인 후 실행 — 사용자 규칙)
|
||||||
|
|
||||||
|
4. **테스트:**
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Token: {토큰}" http://your-server-ip:5093/api/sync/export
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **추가 의존성:** `pip install markdownify` (Phase 2 스크립트에서 필요)
|
||||||
+26
-75
@@ -1,4 +1,4 @@
|
|||||||
# 📔 뇌사료 (Brain Dogfood) 사용자 매뉴얼 (v5.0+)
|
# 📔 뇌사료 (Brain Dogfood) 사용자 매뉴얼 (v1.5)
|
||||||
|
|
||||||
'뇌사료' 프로젝트에 오신 것을 환영합니다! 본 매뉴얼은 파편화된 영감을 체계적인 지식 성단(Nebula)으로 구축하는 데 필요한 모든 가이드를 제공합니다.
|
'뇌사료' 프로젝트에 오신 것을 환영합니다! 본 매뉴얼은 파편화된 영감을 체계적인 지식 성단(Nebula)으로 구축하는 데 필요한 모든 가이드를 제공합니다.
|
||||||
|
|
||||||
@@ -8,105 +8,56 @@
|
|||||||
|
|
||||||
메인 전면에 펼쳐진 **지식 네뷸라**는 단순한 목록이 아닌 지식의 유기적인 지도를 보여줍니다.
|
메인 전면에 펼쳐진 **지식 네뷸라**는 단순한 목록이 아닌 지식의 유기적인 지도를 보여줍니다.
|
||||||
|
|
||||||
- **노드(Node)**: 각각의 메모를 상징합니다.
|
- **노드(Node)**: 각각의 메모를 상징하며, 그룹 색상을 따릅니다.
|
||||||
- **크기**: 내용이 많거나 연결이 많을수록 노드가 거대해집니다.
|
- **링크(Link)**: `[[#ID]]` 문법으로 명시적으로 연결된 관계를 시선과 인력으로 표현합니다.
|
||||||
- **색상**: 각 메모에 설정된 고유한 그룹 색상을 따릅니다.
|
|
||||||
- **🔒 아이콘**: 암호화된 메모임을 나타내며, 제목만 미리보기로 제공됩니다.
|
|
||||||
- **링크(Link)**:
|
|
||||||
- **실선**: `[[#ID]]` 문법으로 명시적으로 연결된 관계입니다.
|
|
||||||
- **인력(Gravity)**: 같은 그룹이나 공통 태그를 가진 메모들은 서로를 끌어당겨 가까이 배치됩니다.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌡️ 3. 지식 성장 히트맵 (Heatmap) 사용법
|
## 🌡️ 2. 지식 성장 히트맵 (Heatmap) 사용법
|
||||||
|
|
||||||
사이드바에 위치한 히트맵은 사용자의 기록 강도를 시각적으로 보여줍니다.
|
사이드바의 히트맵은 최근 1년간의 지식 축적 활동량을 시각적으로 보여줍니다.
|
||||||
|
|
||||||
- **기간 전환**: 히트맵 상단 제목 옆의 드롭다운을 통해 **1개월 / 3개월 / 6개월 / 1년** 단위를 선택할 수 있습니다.
|
- **기간 전환**: 상단 드롭다운을 통해 **1개월 / 3개월 / 6개월 / 1년** 단위를 선택할 수 있습니다.
|
||||||
- **상태 유지**: 한 번 선택한 기간은 브라우저에 저장되어 다음 접속 시에도 그대로 유지됩니다.
|
- **언어 연동**: 시스템 언어 설정에 따라 제목과 범례("Less"/"More")가 자동으로 번역됩니다.
|
||||||
- **활동량 확인**: 각 칸에 마우스를 올리면 해당 날짜에 작성된 메모의 개수를 확인할 수 있습니다. 색상이 진해질수록(Cyan -> Purple) 더 많은 지식을 축적했음을 의미합니다.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✍️ 4. 메모 작성 및 스타일링
|
## ✍️ 3. 메모 작성 및 스타일링
|
||||||
|
|
||||||
### 4.1 지식 연결 문법 (`[[#ID]]`)
|
### 3.1 지식 연결 문법 (`[[#ID]]`)
|
||||||
메모 간의 명시적인 지식을 연결하려면 본문에 샵(#) 기호와 메모 번호를 사용하세요.
|
메모 간의 명시적인 지식을 연결하려면 본문에 샵(#) 기호와 메모 번호를 사용하세요.
|
||||||
> 예: "이 개념은 `[[#12]]`에서 다룬 내용과 상충됩니다."
|
|
||||||
- **효과**: 뷰어에서 클릭 시 해당 메모로 바로 이동하며, 지식 네뷸라 상에 강력한 연결선이 형성됩니다.
|
- **효과**: 뷰어에서 클릭 시 해당 메모로 바로 이동하며, 지식 네뷸라 상에 강력한 연결선이 형성됩니다.
|
||||||
|
|
||||||
### 4.2 컬러 텍스트 (Color Syntax)
|
### 3.2 컬러 텍스트 (Color Syntax)
|
||||||
에디터 상단 툴바의 **색상 선택 아이콘**을 사용하여 텍스트에 색상을 입힐 수 있습니다. 중요 키워드를 강조하여 지식의 가독성을 높이세요.
|
에디터 툴바의 색상 아이콘으로 지식의 가독성을 높입니다.
|
||||||
|
|
||||||
### 4.3 개별 메모 암호화
|
### 3.3 V5 메타데이터 쉴드
|
||||||
중요한 개인 정보나 아이디어는 암호화하여 보호할 수 있습니다.
|
시스템이 생성하는 하단 메타데이터는 자동으로 관리되므로 본문 작성에만 집중하면 됩니다.
|
||||||
- **사용법**: 편집기 하단의 **[암호화 사용]** 체크 -> 비밀번호 설정.
|
|
||||||
- **특약**: 암호화된 메모는 서버 측에서도 해독이 불가능하며, 비밀번호 분실 시 복구가 절대 불가능하므로 주의하세요.
|
|
||||||
- **복호화**: 작성된 암호화 메모 옆의 **🔓 해독** 버튼을 눌러 비밀번호 입력 시 일시적으로 내용을 확인할 수 있습니다.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
---
|
## ⚙️ 4. 설정 및 커스터마이징 (v1.5 신규)
|
||||||
|
|
||||||
## 🧠 5. AI 인텔리전스 (AI Insights)
|
우측 상단의 **[⚙️ 설정]** 버튼을 통해 나만의 지식 환경을 구축할 수 있습니다.
|
||||||
|
|
||||||
### 5.1 AI 활성화 및 API 키 설정 (초보자 가이드)
|
### 4.1 고급 설정 (카테고리 활성화)
|
||||||
'뇌사료'의 지능형 기능을 사용하려면 Google의 Gemini API 키가 필요합니다. 다음 단계에 따라 **1분 만에 무료로** 설정을 마칠 수 있습니다.
|
- **라이트 모드 (기본)**: 카테고리 기능이 숨겨져 있어 태그와 그룹만으로 심플하게 기록할 수 있습니다.
|
||||||
|
- **고급 모드**: 설정에서 **"카테고리 기능(고급) 활성화"**를 체크하면, 작성기 하단에 카테고리 선택 칩이 나타나며 사이드바에서도 카테고리별 분류가 활성화됩니다.
|
||||||
|
|
||||||
1. **키 발급**: [Google AI Studio (https://aistudio.google.com/app/apikey)](https://aistudio.google.com/app/apikey)에 접속합니다.
|
### 4.2 다국어 설정 (Language)
|
||||||
2. **프로젝트 생성**: "Create API key in new project" 버튼을 누릅니다.
|
- **한국어 / English**: 선호하는 언어를 선택하고 [Save]를 누르면 즉시 전체 UI와 달력, 히트맵 등의 도구가 해당 언어로 재초기화됩니다.
|
||||||
3. **키 복사**: 생성된 `AIza...`로 시작하는 긴 문자열을 복사합니다.
|
|
||||||
4. **서버 적용**: 본 프로젝트 폴더의 `.env` 파일을 열고 `GEMINI_API_KEY=` 뒤에 복사한 키를 붙여넣고 저장합니다.
|
|
||||||
5. **활성화**: 앱 우상단 **[⚙️ 설정]** -> **AI 기능 활성화** 체크박스를 켜고 저장합니다.
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> - API 키 발급은 완전히 무료이며, 개인적인 용도로는 충분한 할당량이 제공됩니다.
|
|
||||||
> - 키가 없더라도 메모 작성 및 시각화 등 기본적인 기능은 "NO AI" 모드로 완벽하게 작동합니다.
|
|
||||||
|
|
||||||
### 5.2 주요 기능
|
|
||||||
- **자동 요약**: 방대한 내용을 AI가 1~2문장의 핵심 문장으로 압축해줍니다.
|
|
||||||
- **스마트 태그**: 본문을 분석하여 자동으로 추천 태그를 생성합니다.
|
|
||||||
- **추론형 배치**: AI가 생성한 태그를 기반으로 지식 네뷸라 상에서 비슷한 맥락의 메모들이 자동으로 성단을 형성합니다.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
---
|
## 🔒 5. 보안 및 백업
|
||||||
|
- **개별 암호화**: 편집기 하단 [암호화 사용]을 통해 메모를 안전하게 잠글 수 있습니다.
|
||||||
## ⌨️ 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)
|
## 🚀 6. 운영 및 관리 (Ops)
|
||||||
|
- **`deploy.py`**: 개발 환경의 코드를 서버로 안전하게 배포합니다.
|
||||||
### 7.1 정밀 배포 (`deploy.py`)
|
- **`backup.py`**: 서버의 핵심 데이터를 정기적으로 백업합니다.
|
||||||
개발 환경에서 작업한 코드를 서버로 안전하게 배포합니다.
|
|
||||||
```bash
|
|
||||||
python deploy.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 재난 복구 백업 (`backup.py`)
|
|
||||||
서버의 모든 핵심 데이터를 압축하여 안전하게 보관합니다.
|
|
||||||
```bash
|
|
||||||
python backup.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
# 📱 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*
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# --- 🧠 뇌사료 서버 관리 스크립트 ---
|
||||||
|
|
||||||
|
APP_NAME="brain.py"
|
||||||
|
PID_FILE="server.pid"
|
||||||
|
LOG_FILE="logs/console.log"
|
||||||
|
|
||||||
|
# 로그 디렉토리 생성 확인
|
||||||
|
mkdir -p logs
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if [ -f $PID_FILE ]; then
|
||||||
|
PID=$(cat $PID_FILE)
|
||||||
|
if ps -p $PID > /dev/null; then
|
||||||
|
echo "⚠️ 서버가 이미 실행 중입니다. (PID: $PID)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 서버를 백그라운드에서 시작합니다... (Port: 5093)"
|
||||||
|
# nohup으로 실행하여 세션 종료 후에도 유지
|
||||||
|
nohup python3 $APP_NAME > $LOG_FILE 2>&1 &
|
||||||
|
|
||||||
|
# PID 저장
|
||||||
|
echo $! > $PID_FILE
|
||||||
|
echo "✅ 서버 기동 완료! (PID: $!)"
|
||||||
|
echo "📝 콘솔 로그: $LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if [ -f $PID_FILE ]; then
|
||||||
|
PID=$(cat $PID_FILE)
|
||||||
|
echo "🛑 서버를 중지하는 중... (PID: $PID)"
|
||||||
|
kill $PID
|
||||||
|
rm $PID_FILE
|
||||||
|
echo "✅ 서버가 중지되었습니다."
|
||||||
|
else
|
||||||
|
echo "⚠️ 실행 중인 서버의 PID 파일을 찾을 수 없습니다."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
if [ -f $PID_FILE ]; then
|
||||||
|
PID=$(cat $PID_FILE)
|
||||||
|
if ps -p $PID > /dev/null; then
|
||||||
|
echo "🟢 서버가 현재 실행 중입니다. (PID: $PID)"
|
||||||
|
else
|
||||||
|
echo "🔴 PID 파일은 있으나 프로세스가 존재하지 않습니다."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚪ 서버가 중지 상태입니다."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
start)
|
||||||
|
start
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
stop
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
stop
|
||||||
|
sleep 2
|
||||||
|
start
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
status
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "사용법: $0 {start|stop|restart|status}"
|
||||||
|
exit 1
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
+23
-2
@@ -10,6 +10,7 @@ import { CalendarManager } from './js/components/CalendarManager.js';
|
|||||||
import { Visualizer } from './js/components/Visualizer.js';
|
import { Visualizer } from './js/components/Visualizer.js';
|
||||||
import { HeatmapManager } from './js/components/HeatmapManager.js';
|
import { HeatmapManager } from './js/components/HeatmapManager.js';
|
||||||
import { DrawerManager } from './js/components/DrawerManager.js';
|
import { DrawerManager } from './js/components/DrawerManager.js';
|
||||||
|
import { CategoryManager } from './js/components/CategoryManager.js';
|
||||||
import { ModalManager } from './js/components/ModalManager.js';
|
import { ModalManager } from './js/components/ModalManager.js';
|
||||||
import { I18nManager } from './js/utils/I18nManager.js';
|
import { I18nManager } from './js/utils/I18nManager.js';
|
||||||
import { Constants } from './js/utils/Constants.js';
|
import { Constants } from './js/utils/Constants.js';
|
||||||
@@ -27,17 +28,20 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
AppService.setFilter({ date }, updateSidebarCallback);
|
AppService.setFilter({ date }, updateSidebarCallback);
|
||||||
});
|
});
|
||||||
DrawerManager.init();
|
DrawerManager.init();
|
||||||
|
CategoryManager.init(() => AppService.refreshData(updateSidebarCallback));
|
||||||
Visualizer.init('graphContainer');
|
Visualizer.init('graphContainer');
|
||||||
UI.initSidebarToggle();
|
UI.initSidebarToggle();
|
||||||
|
|
||||||
// --- 🔹 Callbacks ---
|
// --- 🔹 Callbacks ---
|
||||||
const updateSidebarCallback = (memos, activeGroup) => {
|
const updateSidebarCallback = (memos, activeGroup, activeCategory) => {
|
||||||
UI.updateSidebar(memos, activeGroup, (newFilter) => {
|
UI.updateSidebar(memos, activeGroup, activeCategory, (newFilter) => {
|
||||||
if (newFilter === Constants.GROUPS.FILES) {
|
if (newFilter === Constants.GROUPS.FILES) {
|
||||||
ModalManager.openAssetLibrary((id, ms) => UI.openMemoModal(id, ms));
|
ModalManager.openAssetLibrary((id, ms) => UI.openMemoModal(id, ms));
|
||||||
} else {
|
} else {
|
||||||
AppService.setFilter({ group: newFilter }, updateSidebarCallback);
|
AppService.setFilter({ group: newFilter }, updateSidebarCallback);
|
||||||
}
|
}
|
||||||
|
}, (newCat) => {
|
||||||
|
AppService.setFilter({ category: newCat }, updateSidebarCallback);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,6 +142,13 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- 🔹 Category Management ---
|
||||||
|
document.getElementById('manageCategoryBtn').onclick = () => {
|
||||||
|
CategoryManager.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 🔹 Global Shortcuts (Comprehensive Shift to Ctrl-based System) ---
|
||||||
|
|
||||||
// --- 🔹 Global Shortcuts (Comprehensive Shift to Ctrl-based System) ---
|
// --- 🔹 Global Shortcuts (Comprehensive Shift to Ctrl-based System) ---
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
const isCtrl = e.ctrlKey || e.metaKey;
|
const isCtrl = e.ctrlKey || e.metaKey;
|
||||||
@@ -189,6 +200,16 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
if (isAlt && key === '`') {
|
if (isAlt && key === '`') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
ComposerManager.openEmpty();
|
ComposerManager.openEmpty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Category Slots: Alt + 1~4
|
||||||
|
if (isAlt && (key >= '1' && key <= '4')) {
|
||||||
|
if (ComposerManager.DOM.composer.style.display === 'block') {
|
||||||
|
e.preventDefault();
|
||||||
|
const slotIndex = parseInt(key) - 1; // 1->0 (Done), 2->1 (Cat1)...
|
||||||
|
ComposerManager.toggleCategoryBySlot(slotIndex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -105,3 +105,155 @@
|
|||||||
|
|
||||||
.action-btn:hover { background: rgba(184, 59, 94, 0.8); }
|
.action-btn:hover { background: rgba(184, 59, 94, 0.8); }
|
||||||
.ai-btn:hover { background: var(--ai-accent); color: white; }
|
.ai-btn:hover { background: var(--ai-accent); color: white; }
|
||||||
|
|
||||||
|
/* Memo Footer Metadata Styling */
|
||||||
|
.memo-metadata-footer {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px dashed rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memo-metadata-footer p {
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Composer Category Chips === */
|
||||||
|
#composerCategoryBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 15px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-chip {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-chip:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: var(--accent-light);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-chip.active {
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-light);
|
||||||
|
box-shadow: 0 4px 12px rgba(56, 189, 248, 0.3);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-chip.done-chip.active {
|
||||||
|
background: #10b981; /* Success Green */
|
||||||
|
border-color: #10b981;
|
||||||
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 💡 외부 카테고리 강조칩 (핀에 없지만 지정된 경우) */
|
||||||
|
.cat-chip.external-active {
|
||||||
|
border: 1px dashed #ff4d4d !important;
|
||||||
|
background: rgba(255, 77, 77, 0.05);
|
||||||
|
color: #ff4d4d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-chip.external-active:hover {
|
||||||
|
background: rgba(255, 77, 77, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-chip kbd {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-left: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category Management List */
|
||||||
|
.cat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-item .cat-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-action-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 2px;
|
||||||
|
opacity: 0.4;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-action-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-action-btn.pin.active {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--accent);
|
||||||
|
filter: drop-shadow(0 0 5px var(--accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-action-btn.delete:hover {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar for Category List */
|
||||||
|
#categoryListContainer::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
#categoryListContainer::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
#categoryListContainer::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
#categoryListContainer::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|||||||
+24
-9
@@ -10,6 +10,7 @@ export const AppService = {
|
|||||||
state: {
|
state: {
|
||||||
memosCache: [],
|
memosCache: [],
|
||||||
currentFilterGroup: 'all',
|
currentFilterGroup: 'all',
|
||||||
|
currentFilterCategory: null, // NEW: 카테고리 필터
|
||||||
currentFilterDate: null,
|
currentFilterDate: null,
|
||||||
currentSearchQuery: '',
|
currentSearchQuery: '',
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -42,11 +43,11 @@ export const AppService = {
|
|||||||
if (this.state.isLoading || !this.state.hasMore) return;
|
if (this.state.isLoading || !this.state.hasMore) return;
|
||||||
|
|
||||||
this.state.isLoading = true;
|
this.state.isLoading = true;
|
||||||
// UI.showLoading(true)는 호출부에서 관리하거나 여기서 직접 호출 가능
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const filters = {
|
const filters = {
|
||||||
group: this.state.currentFilterGroup,
|
group: this.state.currentFilterGroup,
|
||||||
|
category: this.state.currentFilterCategory, // NEW
|
||||||
date: this.state.currentFilterDate,
|
date: this.state.currentFilterDate,
|
||||||
query: this.state.currentSearchQuery,
|
query: this.state.currentSearchQuery,
|
||||||
offset: this.state.offset,
|
offset: this.state.offset,
|
||||||
@@ -68,13 +69,10 @@ export const AppService = {
|
|||||||
|
|
||||||
this.state.offset += newMemos.length;
|
this.state.offset += newMemos.length;
|
||||||
|
|
||||||
// 캘린더 점 표시는 첫 로드 시에면 하면 부족할 수 있으므로,
|
|
||||||
// 필요 시 전체 데이터를 새로 고침하는 별도 API가 필요할 수 있음.
|
|
||||||
// 여기서는 현재 캐시된 데이터 기반으로 업데이트.
|
|
||||||
CalendarManager.updateMemoDates(this.state.memosCache);
|
CalendarManager.updateMemoDates(this.state.memosCache);
|
||||||
|
|
||||||
if (onUpdateSidebar) {
|
if (onUpdateSidebar) {
|
||||||
onUpdateSidebar(this.state.memosCache, this.state.currentFilterGroup);
|
onUpdateSidebar(this.state.memosCache, this.state.currentFilterGroup, this.state.currentFilterCategory);
|
||||||
}
|
}
|
||||||
|
|
||||||
UI.setHasMore(this.state.hasMore);
|
UI.setHasMore(this.state.hasMore);
|
||||||
@@ -90,17 +88,34 @@ export const AppService = {
|
|||||||
/**
|
/**
|
||||||
* 필터 상태를 변경하고 데이터 초기화 후 다시 로딩
|
* 필터 상태를 변경하고 데이터 초기화 후 다시 로딩
|
||||||
*/
|
*/
|
||||||
async setFilter({ group, date, query }, onUpdateSidebar) {
|
async setFilter({ group, category, date, query }, onUpdateSidebar) {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
if (group !== undefined && this.state.currentFilterGroup !== group) {
|
|
||||||
this.state.currentFilterGroup = group;
|
// 1. 그룹 선택 처리
|
||||||
|
if (group !== undefined) {
|
||||||
|
// 그룹이 바뀌거나, 혹은 카테고리가 켜져있는 상태에서 그룹을 누르면 카테고리 해제
|
||||||
|
if (this.state.currentFilterGroup !== group || this.state.currentFilterCategory !== null) {
|
||||||
|
this.state.currentFilterGroup = group;
|
||||||
|
this.state.currentFilterCategory = null;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 카테고리 선택 처리
|
||||||
|
if (category !== undefined) {
|
||||||
|
if (this.state.currentFilterCategory === category) {
|
||||||
|
// 이미 선택된 카테고리 재클릭 시 해제 (Toggle)
|
||||||
|
this.state.currentFilterCategory = null;
|
||||||
|
} else {
|
||||||
|
this.state.currentFilterCategory = category;
|
||||||
|
}
|
||||||
|
this.state.currentFilterGroup = 'all'; // 카테고리 필터 적용/변경 시 그룹 초기화
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
if (date !== undefined && this.state.currentFilterDate !== date) {
|
if (date !== undefined && this.state.currentFilterDate !== date) {
|
||||||
this.state.currentFilterDate = date;
|
this.state.currentFilterDate = date;
|
||||||
changed = true;
|
changed = true;
|
||||||
|
|
||||||
// UI 동기화
|
|
||||||
CalendarManager.setSelectedDate(date);
|
CalendarManager.setSelectedDate(date);
|
||||||
if (HeatmapManager.setSelectedDate) {
|
if (HeatmapManager.setSelectedDate) {
|
||||||
HeatmapManager.setSelectedDate(date);
|
HeatmapManager.setSelectedDate(date);
|
||||||
|
|||||||
+14
-4
@@ -18,12 +18,22 @@ export const API = {
|
|||||||
|
|
||||||
async fetchMemos(filters = {}) {
|
async fetchMemos(filters = {}) {
|
||||||
const { limit = 20, offset = 0, group = 'all', query = '' } = filters;
|
const { limit = 20, offset = 0, group = 'all', query = '' } = filters;
|
||||||
const date = filters.date || ''; // null이나 undefined를 빈 문자열로 변환
|
const date = filters.date || '';
|
||||||
const params = new URLSearchParams({ limit, offset, group, query, date });
|
const category = (filters.category === null || filters.category === undefined) ? '' : filters.category;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
group,
|
||||||
|
query,
|
||||||
|
category,
|
||||||
|
date,
|
||||||
|
_t: Date.now() // 브라우저 캐시 방지용 타임스탬프
|
||||||
|
});
|
||||||
return await this.request(`/api/memos?${params.toString()}`);
|
return await this.request(`/api/memos?${params.toString()}`);
|
||||||
},
|
},
|
||||||
async fetchHeatmapData(days = 365) {
|
async fetchHeatmapData(days = 365) {
|
||||||
return await this.request(`/api/stats/heatmap?days=${days}`);
|
return await this.request(`/api/stats/heatmap?days=${days}&_t=${Date.now()}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveMemo(payload, id = null) {
|
async saveMemo(payload, id = null) {
|
||||||
@@ -52,7 +62,7 @@ export const API = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchAssets() {
|
async fetchAssets() {
|
||||||
return await this.request('/api/assets');
|
return await this.request(`/api/assets?_t=${Date.now()}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async uploadFile(file) {
|
async uploadFile(file) {
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* 카테고리 관리 모달 (Category Management Modal)
|
||||||
|
*/
|
||||||
|
import { API } from '../api.js';
|
||||||
|
import { I18nManager } from '../utils/I18nManager.js';
|
||||||
|
import { ThemeManager } from './ThemeManager.js';
|
||||||
|
|
||||||
|
export const CategoryManager = {
|
||||||
|
DOM: {},
|
||||||
|
onUpdateCallback: null,
|
||||||
|
|
||||||
|
init(onUpdateCallback) {
|
||||||
|
this.onUpdateCallback = onUpdateCallback;
|
||||||
|
this.DOM = {
|
||||||
|
modal: document.getElementById('categoryModal'),
|
||||||
|
closeBtn: document.getElementById('closeCategoryBtn'),
|
||||||
|
container: document.getElementById('categoryListContainer'),
|
||||||
|
input: document.getElementById('newCategoryInput'),
|
||||||
|
addBtn: document.getElementById('addCategoryBtn')
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.DOM.modal) return;
|
||||||
|
|
||||||
|
this.DOM.closeBtn.onclick = () => this.close();
|
||||||
|
this.DOM.addBtn.onclick = () => this.handleAdd();
|
||||||
|
this.DOM.input.onkeydown = (e) => { if (e.key === 'Enter') this.handleAdd(); };
|
||||||
|
|
||||||
|
window.addEventListener('click', (e) => {
|
||||||
|
if (e.target === this.DOM.modal) this.close();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
open() {
|
||||||
|
this.render();
|
||||||
|
this.DOM.modal.classList.add('active');
|
||||||
|
this.DOM.input.focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.DOM.modal.classList.remove('active');
|
||||||
|
},
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
const settings = ThemeManager.settings || {};
|
||||||
|
const categories = settings.categories || [];
|
||||||
|
const pinned = settings.pinned_categories || [];
|
||||||
|
|
||||||
|
this.DOM.container.innerHTML = '';
|
||||||
|
|
||||||
|
categories.forEach(cat => {
|
||||||
|
const isPinned = pinned.includes(cat);
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'cat-item';
|
||||||
|
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="cat-name">${cat}</div>
|
||||||
|
<div class="cat-actions">
|
||||||
|
<button class="cat-action-btn pin ${isPinned ? 'active' : ''}" title="Pin/Unpin Slot">
|
||||||
|
${isPinned ? '📍' : '📌'}
|
||||||
|
</button>
|
||||||
|
<button class="cat-action-btn delete" title="Delete Category">🗑️</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 핀 토글
|
||||||
|
item.querySelector('.pin').onclick = () => this.togglePin(cat);
|
||||||
|
|
||||||
|
// 삭제
|
||||||
|
item.querySelector('.delete').onclick = () => this.deleteCategory(cat);
|
||||||
|
|
||||||
|
this.DOM.container.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (categories.length === 0) {
|
||||||
|
this.DOM.container.innerHTML = `<p style="text-align:center; color:var(--muted); font-size:0.9rem; padding:20px;">${I18nManager.t('label_no_category')}</p>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleAdd() {
|
||||||
|
const name = this.DOM.input.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
if (name.length > 20) {
|
||||||
|
alert("Name too long (max 20)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = { ...ThemeManager.settings };
|
||||||
|
if (settings.categories.includes(name)) {
|
||||||
|
alert("Already exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.categories.push(name);
|
||||||
|
// 공간이 있으면 자동 핀 고정
|
||||||
|
if (settings.pinned_categories.length < 3) {
|
||||||
|
settings.pinned_categories.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.save(settings);
|
||||||
|
this.DOM.input.value = '';
|
||||||
|
this.render();
|
||||||
|
},
|
||||||
|
|
||||||
|
async togglePin(cat) {
|
||||||
|
const settings = { ...ThemeManager.settings };
|
||||||
|
const idx = settings.pinned_categories.indexOf(cat);
|
||||||
|
|
||||||
|
if (idx > -1) {
|
||||||
|
settings.pinned_categories.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
if (settings.pinned_categories.length >= 3) {
|
||||||
|
alert(I18nManager.t('msg_category_limit'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settings.pinned_categories.push(cat);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.save(settings);
|
||||||
|
this.render();
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteCategory(cat) {
|
||||||
|
if (!confirm(I18nManager.t('msg_confirm_delete_category'))) return;
|
||||||
|
|
||||||
|
const settings = { ...ThemeManager.settings };
|
||||||
|
settings.categories = settings.categories.filter(c => c !== cat);
|
||||||
|
settings.pinned_categories = settings.pinned_categories.filter(c => c !== cat);
|
||||||
|
|
||||||
|
await this.save(settings);
|
||||||
|
this.render();
|
||||||
|
},
|
||||||
|
|
||||||
|
async save(settings) {
|
||||||
|
try {
|
||||||
|
await API.saveSettings(settings);
|
||||||
|
// 전역 세팅 업데이트 및 UI 리프레시
|
||||||
|
ThemeManager.settings = settings;
|
||||||
|
if (window.UI) window.UI._updateSettingsCache(settings);
|
||||||
|
if (this.onUpdateCallback) this.onUpdateCallback();
|
||||||
|
} catch (err) {
|
||||||
|
alert("Save failed: " + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,12 +5,17 @@ import { API } from '../api.js';
|
|||||||
import { EditorManager } from '../editor.js';
|
import { EditorManager } from '../editor.js';
|
||||||
import { I18nManager } from '../utils/I18nManager.js';
|
import { I18nManager } from '../utils/I18nManager.js';
|
||||||
import { Constants } from '../utils/Constants.js';
|
import { Constants } from '../utils/Constants.js';
|
||||||
|
import { AppService } from '../AppService.js';
|
||||||
|
import { ThemeManager } from './ThemeManager.js';
|
||||||
|
|
||||||
|
// --- NEW 서브 모듈 임포트 ---
|
||||||
|
import { ComposerDraft } from './composer/ComposerDraft.js';
|
||||||
|
import { ComposerCategoryUI } from './composer/ComposerCategoryUI.js';
|
||||||
|
|
||||||
export const ComposerManager = {
|
export const ComposerManager = {
|
||||||
DOM: {},
|
DOM: {},
|
||||||
|
|
||||||
init(onSaveSuccess) {
|
init(onSaveSuccess) {
|
||||||
// 타이밍 이슈 방지를 위해 DOM 요소 지연 할당
|
|
||||||
this.DOM = {
|
this.DOM = {
|
||||||
trigger: document.getElementById('composerTrigger'),
|
trigger: document.getElementById('composerTrigger'),
|
||||||
composer: document.getElementById('composer'),
|
composer: document.getElementById('composer'),
|
||||||
@@ -21,11 +26,15 @@ export const ComposerManager = {
|
|||||||
encryptionToggle: document.getElementById('encryptionToggle'),
|
encryptionToggle: document.getElementById('encryptionToggle'),
|
||||||
password: document.getElementById('memoPassword'),
|
password: document.getElementById('memoPassword'),
|
||||||
foldBtn: document.getElementById('foldBtn'),
|
foldBtn: document.getElementById('foldBtn'),
|
||||||
discardBtn: document.getElementById('discardBtn')
|
discardBtn: document.getElementById('discardBtn'),
|
||||||
|
categoryBar: document.getElementById('composerCategoryBar')
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!this.DOM.composer || !this.DOM.trigger) return;
|
if (!this.DOM.composer || !this.DOM.trigger) return;
|
||||||
|
|
||||||
|
this.selectedCategory = null;
|
||||||
|
this.isDoneStatus = false;
|
||||||
|
|
||||||
// 1. 이벤트 바인딩
|
// 1. 이벤트 바인딩
|
||||||
this.DOM.trigger.onclick = () => this.openEmpty();
|
this.DOM.trigger.onclick = () => this.openEmpty();
|
||||||
this.DOM.foldBtn.onclick = () => this.close();
|
this.DOM.foldBtn.onclick = () => this.close();
|
||||||
@@ -44,28 +53,41 @@ export const ComposerManager = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.DOM.encryptionToggle.onclick = () => this.toggleEncryption();
|
this.DOM.encryptionToggle.onclick = () => this.toggleEncryption();
|
||||||
|
this.initShortcutHint();
|
||||||
// 단축키 힌트 토글 바인딩
|
|
||||||
const shortcutToggle = document.getElementById('shortcutToggle');
|
// 2. 자동 임시저장 및 키보드 리스너 등록
|
||||||
const shortcutDetails = document.getElementById('shortcutDetails');
|
this.draftTimer = setInterval(() => this.saveDraft(), 3000);
|
||||||
if (shortcutToggle && shortcutDetails) {
|
ComposerDraft.checkRestore((draft) => this.restoreDraft(draft));
|
||||||
shortcutToggle.onclick = () => {
|
document.addEventListener('keydown', (e) => this.handleKeyDown(e));
|
||||||
const isVisible = shortcutDetails.style.display !== 'none';
|
},
|
||||||
shortcutDetails.style.display = isVisible ? 'none' : 'flex';
|
|
||||||
const label = I18nManager.t('shortcuts_label');
|
initShortcutHint() {
|
||||||
shortcutToggle.textContent = isVisible ? label : `${label} ▲`;
|
const toggle = document.getElementById('shortcutToggle');
|
||||||
|
const details = document.getElementById('shortcutDetails');
|
||||||
|
if (toggle && details) {
|
||||||
|
toggle.onclick = () => {
|
||||||
|
const isVisible = details.style.display !== 'none';
|
||||||
|
details.style.display = isVisible ? 'none' : 'flex';
|
||||||
|
toggle.textContent = isVisible ? I18nManager.t('shortcuts_label') : `${I18nManager.t('shortcuts_label')} ▲`;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 자동 임시저장 (Auto-Draft) ---
|
|
||||||
this.draftTimer = setInterval(() => this.saveDraft(), 3000);
|
|
||||||
this.checkDraftRestore();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
openEmpty() {
|
openEmpty() {
|
||||||
this.clear();
|
this.clear();
|
||||||
|
|
||||||
|
// 컨텍스트 기반 그룹 자동 설정 (all, done, tag 제외)
|
||||||
|
const currentGroup = AppService.state.currentFilterGroup;
|
||||||
|
if (currentGroup &&
|
||||||
|
currentGroup !== 'all' &&
|
||||||
|
currentGroup !== Constants.GROUPS.DONE &&
|
||||||
|
!currentGroup.startsWith('tag:')) {
|
||||||
|
this.DOM.group.value = currentGroup;
|
||||||
|
}
|
||||||
|
|
||||||
this.DOM.composer.style.display = 'block';
|
this.DOM.composer.style.display = 'block';
|
||||||
this.DOM.trigger.style.display = 'none';
|
this.DOM.trigger.style.display = 'none';
|
||||||
|
this.renderCategoryChips(); // 💡 초기화 후 칩 렌더링
|
||||||
this.DOM.title.focus();
|
this.DOM.title.focus();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -77,6 +99,10 @@ export const ComposerManager = {
|
|||||||
this.DOM.group.value = memo.group_name || Constants.GROUPS.DEFAULT;
|
this.DOM.group.value = memo.group_name || Constants.GROUPS.DEFAULT;
|
||||||
this.DOM.tags.value = (memo.tags || []).filter(t => t.source === 'user').map(t => t.name).join(', ');
|
this.DOM.tags.value = (memo.tags || []).filter(t => t.source === 'user').map(t => t.name).join(', ');
|
||||||
|
|
||||||
|
// 💡 분류 및 상태 복원
|
||||||
|
this.selectedCategory = memo.category || null;
|
||||||
|
this.isDoneStatus = memo.status === 'done';
|
||||||
|
|
||||||
EditorManager.setMarkdown(memo.content || '');
|
EditorManager.setMarkdown(memo.content || '');
|
||||||
EditorManager.setAttachedFiles(memo.attachments || []);
|
EditorManager.setAttachedFiles(memo.attachments || []);
|
||||||
|
|
||||||
@@ -86,6 +112,7 @@ export const ComposerManager = {
|
|||||||
|
|
||||||
this.DOM.composer.style.display = 'block';
|
this.DOM.composer.style.display = 'block';
|
||||||
this.DOM.trigger.style.display = 'none';
|
this.DOM.trigger.style.display = 'none';
|
||||||
|
this.renderCategoryChips(); // 💡 렌더링
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -94,6 +121,8 @@ export const ComposerManager = {
|
|||||||
title: this.DOM.title.value.trim(),
|
title: this.DOM.title.value.trim(),
|
||||||
content: EditorManager.getMarkdown(),
|
content: EditorManager.getMarkdown(),
|
||||||
group_name: this.DOM.group.value.trim() || Constants.GROUPS.DEFAULT,
|
group_name: this.DOM.group.value.trim() || Constants.GROUPS.DEFAULT,
|
||||||
|
category: this.selectedCategory,
|
||||||
|
status: this.isDoneStatus ? 'done' : 'active',
|
||||||
tags: this.DOM.tags.value.split(',').map(t => t.trim()).filter(t => t),
|
tags: this.DOM.tags.value.split(',').map(t => t.trim()).filter(t => t),
|
||||||
is_encrypted: this.DOM.encryptionToggle.dataset.locked === 'true',
|
is_encrypted: this.DOM.encryptionToggle.dataset.locked === 'true',
|
||||||
password: this.DOM.password.value.trim(),
|
password: this.DOM.password.value.trim(),
|
||||||
@@ -106,7 +135,7 @@ export const ComposerManager = {
|
|||||||
try {
|
try {
|
||||||
await API.saveMemo(data, this.DOM.id.value);
|
await API.saveMemo(data, this.DOM.id.value);
|
||||||
EditorManager.sessionFiles.clear();
|
EditorManager.sessionFiles.clear();
|
||||||
this.clearDraft();
|
ComposerDraft.clear(); // 💡 서브 모듈 위임
|
||||||
if (callback) await callback();
|
if (callback) await callback();
|
||||||
this.clear();
|
this.clear();
|
||||||
this.close();
|
this.close();
|
||||||
@@ -123,9 +152,12 @@ export const ComposerManager = {
|
|||||||
this.DOM.title.value = '';
|
this.DOM.title.value = '';
|
||||||
this.DOM.group.value = Constants.GROUPS.DEFAULT;
|
this.DOM.group.value = Constants.GROUPS.DEFAULT;
|
||||||
this.DOM.tags.value = '';
|
this.DOM.tags.value = '';
|
||||||
|
this.selectedCategory = null;
|
||||||
|
this.isDoneStatus = false;
|
||||||
EditorManager.setMarkdown('');
|
EditorManager.setMarkdown('');
|
||||||
EditorManager.setAttachedFiles([]);
|
EditorManager.setAttachedFiles([]);
|
||||||
this.setLocked(false);
|
this.setLocked(false);
|
||||||
|
this.renderCategoryChips();
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleEncryption() {
|
toggleEncryption() {
|
||||||
@@ -137,89 +169,70 @@ export const ComposerManager = {
|
|||||||
this.DOM.encryptionToggle.dataset.locked = locked;
|
this.DOM.encryptionToggle.dataset.locked = locked;
|
||||||
this.DOM.encryptionToggle.innerText = locked ? '🔒' : '🔓';
|
this.DOM.encryptionToggle.innerText = locked ? '🔒' : '🔓';
|
||||||
this.DOM.password.style.display = locked ? 'block' : 'none';
|
this.DOM.password.style.display = locked ? 'block' : 'none';
|
||||||
|
if (password !== null) this.DOM.password.value = password;
|
||||||
// 비밀번호가 명시적으로 전달된 경우에만 업데이트 (해제 시 기존 비번 유지)
|
if (locked && !this.DOM.password.value) this.DOM.password.focus();
|
||||||
if (password !== null) {
|
|
||||||
this.DOM.password.value = password;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (locked && !this.DOM.password.value) {
|
|
||||||
this.DOM.password.focus();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// === 자동 임시저장 (Auto-Draft) ===
|
// --- 서브 모듈 위임 메서드들 ---
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 에디터 내용을 localStorage에 자동 저장
|
|
||||||
*/
|
|
||||||
saveDraft() {
|
saveDraft() {
|
||||||
// 컴포저가 닫혀있으면 저장하지 않음
|
|
||||||
if (this.DOM.composer.style.display !== 'block') return;
|
if (this.DOM.composer.style.display !== 'block') return;
|
||||||
|
ComposerDraft.save(
|
||||||
const title = this.DOM.title.value;
|
this.DOM.id.value,
|
||||||
const content = EditorManager.getMarkdown();
|
this.DOM.title.value,
|
||||||
|
this.DOM.group.value,
|
||||||
// 내용이 비어있으면 저장하지 않음
|
this.DOM.tags.value,
|
||||||
if (!title && !content) return;
|
EditorManager.getMarkdown()
|
||||||
|
);
|
||||||
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));
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
restoreDraft(draft) {
|
||||||
* 페이지 로드 시 임시저장된 내용이 있으면 복원 확인
|
this.openEmpty();
|
||||||
*/
|
this.DOM.title.value = draft.title || '';
|
||||||
checkDraftRestore() {
|
this.DOM.group.value = draft.group || Constants.GROUPS.DEFAULT;
|
||||||
const raw = localStorage.getItem('memo_draft');
|
this.DOM.tags.value = draft.tags || '';
|
||||||
if (!raw) return;
|
if (draft.editingId) this.DOM.id.value = draft.editingId;
|
||||||
|
EditorManager.setMarkdown(draft.content || '');
|
||||||
|
},
|
||||||
|
|
||||||
try {
|
renderCategoryChips() {
|
||||||
const draft = JSON.parse(raw);
|
ComposerCategoryUI.render(
|
||||||
|
this.DOM.categoryBar,
|
||||||
// 24시간 이상 된 임시저장은 자동 삭제
|
this.selectedCategory,
|
||||||
if (Date.now() - draft.timestamp > 86400000) {
|
this.isDoneStatus,
|
||||||
this.clearDraft();
|
{
|
||||||
return;
|
onSelect: (cat) => {
|
||||||
|
this.selectedCategory = (this.selectedCategory === cat) ? null : cat;
|
||||||
|
this.renderCategoryChips();
|
||||||
|
},
|
||||||
|
onToggleDone: () => {
|
||||||
|
this.isDoneStatus = !this.isDoneStatus;
|
||||||
|
this.renderCategoryChips();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
// 내용이 실제로 있는 경우에만 복원 확인
|
handleKeyDown(e) {
|
||||||
if (!draft.title && !draft.content) {
|
if (this.DOM.composer.style.display !== 'block') return;
|
||||||
this.clearDraft();
|
if (!e.altKey) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const titlePreview = draft.title || I18nManager.t('label_untitled');
|
const key = e.key;
|
||||||
const confirmMsg = I18nManager.t('msg_draft_restore_confirm')
|
if (key === '1') {
|
||||||
.replace('{title}', titlePreview);
|
e.preventDefault();
|
||||||
|
this.isDoneStatus = !this.isDoneStatus;
|
||||||
if (confirm(confirmMsg)) {
|
this.renderCategoryChips();
|
||||||
this.openEmpty();
|
} else if (key === '2' || key === '3' || key === '4') {
|
||||||
this.DOM.title.value = draft.title || '';
|
e.preventDefault();
|
||||||
this.DOM.group.value = draft.group || Constants.GROUPS.DEFAULT;
|
const cat = ComposerCategoryUI.getCategoryBySlot(parseInt(key) - 1);
|
||||||
this.DOM.tags.value = draft.tags || '';
|
if (cat) {
|
||||||
if (draft.editingId) this.DOM.id.value = draft.editingId;
|
this.selectedCategory = (this.selectedCategory === cat) ? null : cat;
|
||||||
EditorManager.setMarkdown(draft.content || '');
|
this.renderCategoryChips();
|
||||||
} else {
|
|
||||||
this.clearDraft();
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} else if (key === '5') {
|
||||||
console.warn('[Draft] Failed to parse draft, deleting:', e);
|
e.preventDefault();
|
||||||
this.clearDraft();
|
this.selectedCategory = null;
|
||||||
|
this.renderCategoryChips();
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 임시저장 데이터 삭제
|
|
||||||
*/
|
|
||||||
clearDraft() {
|
|
||||||
localStorage.removeItem('memo_draft');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,8 +36,13 @@ export function createMemoCardHtml(memo, isDone) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
|
// 본문에서 하단 메타데이터 블록(--- 이후)을 제외하고 렌더링 (중복 표시 방지)
|
||||||
|
let content = memo.content || '';
|
||||||
|
const footerIndex = content.lastIndexOf('\n\n---\n');
|
||||||
|
const displayContent = footerIndex !== -1 ? content.substring(0, footerIndex) : content;
|
||||||
|
|
||||||
// marked로 파싱한 후 DOMPurify로 살균하여 XSS 방지
|
// marked로 파싱한 후 DOMPurify로 살균하여 XSS 방지
|
||||||
htmlContent = DOMPurify.sanitize(marked.parse(memo.content || ''));
|
htmlContent = DOMPurify.sanitize(marked.parse(displayContent));
|
||||||
htmlContent = parseInternalLinks(htmlContent);
|
htmlContent = parseInternalLinks(htmlContent);
|
||||||
htmlContent = fixImagePaths(htmlContent);
|
htmlContent = fixImagePaths(htmlContent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,8 +165,21 @@ export const ModalManager = {
|
|||||||
if (!memo) return;
|
if (!memo) return;
|
||||||
|
|
||||||
import('../utils.js').then(({ parseInternalLinks, fixImagePaths }) => {
|
import('../utils.js').then(({ parseInternalLinks, fixImagePaths }) => {
|
||||||
// 마크다운 파싱 후 살균 처리 (marked, DOMPurify는 global 사용)
|
// 메모 본문과 메타데이터 푸터 분리 렌더링
|
||||||
let html = DOMPurify.sanitize(marked.parse(memo.content));
|
let content = memo.content || '';
|
||||||
|
const footerIndex = content.lastIndexOf('\n\n---\n');
|
||||||
|
let html;
|
||||||
|
|
||||||
|
if (footerIndex !== -1) {
|
||||||
|
const mainBody = content.substring(0, footerIndex);
|
||||||
|
const footerPart = content.substring(footerIndex + 5).trim(); // '---' 이후
|
||||||
|
|
||||||
|
html = DOMPurify.sanitize(marked.parse(mainBody));
|
||||||
|
html += `<div class="memo-metadata-footer"><hr style="border:none; border-top:1px dashed rgba(255,255,255,0.1); margin-bottom:15px;">${DOMPurify.sanitize(marked.parse(footerPart))}</div>`;
|
||||||
|
} else {
|
||||||
|
html = DOMPurify.sanitize(marked.parse(content));
|
||||||
|
}
|
||||||
|
|
||||||
html = parseInternalLinks(html);
|
html = parseInternalLinks(html);
|
||||||
html = fixImagePaths(html);
|
html = fixImagePaths(html);
|
||||||
|
|
||||||
|
|||||||
@@ -45,3 +45,27 @@ export function renderGroupList(container, groups, activeGroup, onGroupClick) {
|
|||||||
container.appendChild(li);
|
container.appendChild(li);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 목록 HTML 렌더링 (Pinned Categories 전용)
|
||||||
|
*/
|
||||||
|
export function renderCategoryList(container, pinnedCategories, activeCategory, onCategoryClick) {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
pinnedCategories.forEach(cat => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = (cat === activeCategory) ? 'active' : '';
|
||||||
|
li.title = cat; // 💡 사이드바 축소 시 이름을 보여주기 위해 title 추가
|
||||||
|
li.innerHTML = `<span class="icon">🏷️</span> <span class="text">${escapeHTML(cat)}</span>`;
|
||||||
|
li.onclick = () => onCategoryClick(cat);
|
||||||
|
container.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pinnedCategories.length === 0) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.style.cssText = 'font-size: 0.8rem; color: var(--muted); padding: 5px 15px; cursor: default;';
|
||||||
|
li.textContent = I18nManager.t('label_no_category');
|
||||||
|
container.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,7 +27,13 @@ export const ThemeManager = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ... 나머지 모달 제어 로직 유지 (기존 코드와 동일)
|
// ... 나머지 모달 제어 로직 유지 (기존 코드와 동일)
|
||||||
if (settingsBtn) settingsBtn.onclick = () => settingsModal.classList.add('active');
|
if (settingsBtn) {
|
||||||
|
settingsBtn.onclick = () => {
|
||||||
|
const langSelect = document.getElementById('set-lang');
|
||||||
|
if (langSelect) this.initialLang = langSelect.value;
|
||||||
|
settingsModal.classList.add('active');
|
||||||
|
};
|
||||||
|
}
|
||||||
if (closeSettingsBtn) closeSettingsBtn.onclick = () => settingsModal.classList.remove('active');
|
if (closeSettingsBtn) closeSettingsBtn.onclick = () => settingsModal.classList.remove('active');
|
||||||
|
|
||||||
window.addEventListener('click', (e) => {
|
window.addEventListener('click', (e) => {
|
||||||
@@ -57,13 +63,23 @@ export const ThemeManager = {
|
|||||||
data[mapping[p.id]] = p.value;
|
data[mapping[p.id]] = p.value;
|
||||||
});
|
});
|
||||||
data['enable_ai'] = document.getElementById('set-enable-ai').checked;
|
data['enable_ai'] = document.getElementById('set-enable-ai').checked;
|
||||||
|
data['enable_categories'] = document.getElementById('set-enable-categories').checked;
|
||||||
|
|
||||||
// 언어 설정이 UI에 있다면 추가 (현재는 config.json 수동 명시 권장이나 대비책 마련)
|
// 언어 설정이 UI에 있다면 추가 (현재는 config.json 수동 명시 권장이나 대비책 마련)
|
||||||
const langSelect = document.getElementById('set-lang');
|
const langSelect = document.getElementById('set-lang');
|
||||||
if (langSelect) data['lang'] = langSelect.value;
|
const newLang = langSelect ? langSelect.value : (this.initialLang || 'ko');
|
||||||
|
if (langSelect) data['lang'] = newLang;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await API.saveSettings(data);
|
await API.saveSettings(data);
|
||||||
|
|
||||||
|
// 언어가 변경되었다면 페이지를 새로고침하여 모든 매니저들을 새로운 언어로 재초기화합니다.
|
||||||
|
if (this.initialLang && this.initialLang !== newLang) {
|
||||||
|
alert(I18nManager.t('msg_settings_saved'));
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.applyTheme(data);
|
await this.applyTheme(data);
|
||||||
alert(I18nManager.t('msg_settings_saved'));
|
alert(I18nManager.t('msg_settings_saved'));
|
||||||
settingsModal.classList.remove('active');
|
settingsModal.classList.remove('active');
|
||||||
@@ -80,7 +96,8 @@ export const ThemeManager = {
|
|||||||
card_color: "rgba(30, 41, 59, 0.85)",
|
card_color: "rgba(30, 41, 59, 0.85)",
|
||||||
encrypted_border: "#00f3ff",
|
encrypted_border: "#00f3ff",
|
||||||
ai_accent: "#8b5cf6",
|
ai_accent: "#8b5cf6",
|
||||||
lang: "ko"
|
lang: "ko",
|
||||||
|
enable_categories: false
|
||||||
};
|
};
|
||||||
this.applyTheme(defaults);
|
this.applyTheme(defaults);
|
||||||
}
|
}
|
||||||
@@ -92,6 +109,11 @@ export const ThemeManager = {
|
|||||||
* 테마 데이터를 실제 CSS 변수 및 UI 요소에 반영
|
* 테마 데이터를 실제 CSS 변수 및 UI 요소에 반영
|
||||||
*/
|
*/
|
||||||
async applyTheme(settings) {
|
async applyTheme(settings) {
|
||||||
|
this.settings = settings; // NEW: 설정 캐시 저장
|
||||||
|
if (window.UI) {
|
||||||
|
window.UI._updateSettingsCache(settings);
|
||||||
|
}
|
||||||
|
|
||||||
const mapping = {
|
const mapping = {
|
||||||
'bg_color': '--bg',
|
'bg_color': '--bg',
|
||||||
'sidebar_color': '--sidebar',
|
'sidebar_color': '--sidebar',
|
||||||
@@ -117,7 +139,15 @@ export const ThemeManager = {
|
|||||||
const aiToggle = document.getElementById('set-enable-ai');
|
const aiToggle = document.getElementById('set-enable-ai');
|
||||||
if (aiToggle) aiToggle.checked = enableAI;
|
if (aiToggle) aiToggle.checked = enableAI;
|
||||||
|
|
||||||
// 3. i18n 적용
|
// 3. 카테고리 활성화 상태 적용 (고급 옵션)
|
||||||
|
const enableCategories = (settings.enable_categories === true);
|
||||||
|
const catToggle = document.getElementById('set-enable-categories');
|
||||||
|
if (catToggle) catToggle.checked = enableCategories;
|
||||||
|
if (window.UI && typeof window.UI.applyCategoryVisibility === 'function') {
|
||||||
|
window.UI.applyCategoryVisibility(enableCategories);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. i18n 적용
|
||||||
const lang = settings.lang || 'ko';
|
const lang = settings.lang || 'ko';
|
||||||
await I18nManager.init(lang);
|
await I18nManager.init(lang);
|
||||||
const langSelect = document.getElementById('set-lang');
|
const langSelect = document.getElementById('set-lang');
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* 작성기 카테고리/핀 UI 렌더링 엔진
|
||||||
|
*/
|
||||||
|
import { I18nManager } from '../../utils/I18nManager.js';
|
||||||
|
import { ThemeManager } from '../ThemeManager.js';
|
||||||
|
|
||||||
|
export const ComposerCategoryUI = {
|
||||||
|
/**
|
||||||
|
* 카테고리 칩 및 상태 UI 렌더링
|
||||||
|
*/
|
||||||
|
render(container, selectedCategory, isDoneStatus, handlers) {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const settings = ThemeManager.settings || {};
|
||||||
|
const slots = settings.pinned_categories || [];
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
// 💡 인라인 스타일 대신 클래스로 관리하거나 layout.css의 #composerCategoryBar 설정을 따름
|
||||||
|
|
||||||
|
// 1. 완료 칩 (Alt + 1)
|
||||||
|
const doneChip = document.createElement('div');
|
||||||
|
doneChip.className = `cat-chip done-chip ${isDoneStatus ? 'active' : ''}`;
|
||||||
|
doneChip.innerHTML = `<span class="icon">✅</span> <span class="text">${I18nManager.t('label_category_done')}</span> <kbd>Alt+1</kbd>`;
|
||||||
|
doneChip.onclick = () => handlers.onToggleDone();
|
||||||
|
container.appendChild(doneChip);
|
||||||
|
|
||||||
|
// 2. 외부 카테고리 강조칩 (핀에 없지만 지정된 경우)
|
||||||
|
const isExternal = selectedCategory && !slots.includes(selectedCategory);
|
||||||
|
if (isExternal) {
|
||||||
|
const extChip = document.createElement('div');
|
||||||
|
extChip.className = 'cat-chip external-active active';
|
||||||
|
extChip.innerHTML = `<span class="icon">📍</span> ${selectedCategory}`;
|
||||||
|
extChip.title = `Current: ${selectedCategory}`;
|
||||||
|
extChip.onclick = () => handlers.onSelect(selectedCategory);
|
||||||
|
container.appendChild(extChip);
|
||||||
|
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'chip-divider';
|
||||||
|
// 구분선 스타일은 CSS에서 관리하거나 최소한으로 유지
|
||||||
|
divider.style.cssText = 'width: 1px; height: 12px; background: var(--muted); opacity: 0.3; margin: 0 5px;';
|
||||||
|
container.appendChild(divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 핀 슬롯(1~3번) 렌더링
|
||||||
|
slots.forEach((cat, idx) => {
|
||||||
|
const slotNum = idx + 2; // 완료(1) 다음부터 시작
|
||||||
|
const key = `shortcut_cat_${idx + 1}`;
|
||||||
|
const label = I18nManager.t(key).replace('%s', cat);
|
||||||
|
|
||||||
|
const chip = document.createElement('div');
|
||||||
|
chip.className = `cat-chip ${selectedCategory === cat ? 'active' : ''}`;
|
||||||
|
chip.innerHTML = `<span class="icon">🏷️</span> <span class="text">${cat}</span> <kbd>Alt+${slotNum}</kbd>`;
|
||||||
|
chip.title = label;
|
||||||
|
chip.onclick = () => handlers.onSelect(cat);
|
||||||
|
container.appendChild(chip);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Alt+5: 분류 해제 힌트
|
||||||
|
const clearHint = document.createElement('div');
|
||||||
|
clearHint.className = 'shortcut-hint';
|
||||||
|
clearHint.textContent = I18nManager.t('shortcut_cat_clear');
|
||||||
|
container.appendChild(clearHint);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 슬롯 인덱스 기반으로 어떤 카테고리를 토글할지 결정
|
||||||
|
*/
|
||||||
|
getCategoryBySlot(index) {
|
||||||
|
const settings = ThemeManager.settings || {};
|
||||||
|
const slots = settings.pinned_categories || [];
|
||||||
|
return slots[index - 1] || null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 작성기 임시저장(Draft) 관리 모듈
|
||||||
|
*/
|
||||||
|
import { I18nManager } from '../../utils/I18nManager.js';
|
||||||
|
import { Constants } from '../../utils/Constants.js';
|
||||||
|
|
||||||
|
export const ComposerDraft = {
|
||||||
|
/**
|
||||||
|
* 현재 에디터 내용을 localStorage에 자동 저장
|
||||||
|
*/
|
||||||
|
save(id, title, group, tags, content) {
|
||||||
|
// 내용이 비어있으면 저장하지 않음
|
||||||
|
if (!title && !content) return;
|
||||||
|
|
||||||
|
const draft = {
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
group: group || Constants.GROUPS.DEFAULT,
|
||||||
|
tags: tags || '',
|
||||||
|
editingId: id || '',
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
localStorage.setItem('memo_draft', JSON.stringify(draft));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 임시저장된 내용이 있는지 확인하고 복원 처리
|
||||||
|
*/
|
||||||
|
checkRestore(onRestore) {
|
||||||
|
const raw = localStorage.getItem('memo_draft');
|
||||||
|
if (!raw) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const draft = JSON.parse(raw);
|
||||||
|
|
||||||
|
// 24시간 이상 된 임시저장은 자동 삭제
|
||||||
|
if (Date.now() - draft.timestamp > 86400000) {
|
||||||
|
this.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 내용이 실제로 있는 경우에만 복원 확인
|
||||||
|
if (!draft.title && !draft.content) {
|
||||||
|
this.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titlePreview = draft.title || I18nManager.t('label_untitled');
|
||||||
|
const confirmMsg = I18nManager.t('msg_draft_restore_confirm')
|
||||||
|
.replace('{title}', titlePreview);
|
||||||
|
|
||||||
|
if (confirm(confirmMsg)) {
|
||||||
|
onRestore(draft);
|
||||||
|
} else {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Draft] Failed to parse draft, deleting:', e);
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 임시저장 데이터 삭제
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
localStorage.removeItem('memo_draft');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -9,6 +9,13 @@ export const EditorManager = {
|
|||||||
sessionFiles: new Set(), // 이번 세션에 새로 추가된 파일 트래킹 (취소 시 삭제용)
|
sessionFiles: new Set(), // 이번 세션에 새로 추가된 파일 트래킹 (취소 시 삭제용)
|
||||||
|
|
||||||
init(elSelector, onCtrlEnter) {
|
init(elSelector, onCtrlEnter) {
|
||||||
|
// 이미 초기화된 경우 기존 에디터 인스턴스 반환 및 중복 방지
|
||||||
|
const container = document.querySelector(elSelector);
|
||||||
|
if (this.editor && container && container.querySelector('.toastui-editor-defaultUI')) {
|
||||||
|
console.log('[Editor] Already initialized, skipping init.');
|
||||||
|
return this.editor;
|
||||||
|
}
|
||||||
|
|
||||||
const isMobile = window.innerWidth <= 768;
|
const isMobile = window.innerWidth <= 768;
|
||||||
|
|
||||||
// --- 플러그인 설정 (글자 색상) ---
|
// --- 플러그인 설정 (글자 색상) ---
|
||||||
|
|||||||
+44
-1
@@ -19,6 +19,9 @@ const DOM = {
|
|||||||
scrollSentinel: document.getElementById('scrollSentinel')
|
scrollSentinel: document.getElementById('scrollSentinel')
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 모듈 레벨의 설정 캐시 관리 (this 바인딩 문제 해결)
|
||||||
|
let settingsCache = {};
|
||||||
|
|
||||||
export const UI = {
|
export const UI = {
|
||||||
/**
|
/**
|
||||||
* 사이드바 및 로그아웃 버튼 초기화
|
* 사이드바 및 로그아웃 버튼 초기화
|
||||||
@@ -98,14 +101,51 @@ export const UI = {
|
|||||||
/**
|
/**
|
||||||
* 사이드바 시스템 고정 메뉴 상태 갱신
|
* 사이드바 시스템 고정 메뉴 상태 갱신
|
||||||
*/
|
*/
|
||||||
updateSidebar(memos, activeGroup, onGroupClick) {
|
updateSidebar(memos, activeGroup, activeCategory, onGroupClick, onCategoryClick) {
|
||||||
if (!DOM.systemNav) return;
|
if (!DOM.systemNav) return;
|
||||||
|
|
||||||
|
// 1. 시스템 그룹 동기화
|
||||||
DOM.systemNav.querySelectorAll('li').forEach(li => {
|
DOM.systemNav.querySelectorAll('li').forEach(li => {
|
||||||
const group = li.dataset.group;
|
const group = li.dataset.group;
|
||||||
li.className = (group === activeGroup) ? 'active' : '';
|
li.className = (group === activeGroup) ? 'active' : '';
|
||||||
li.onclick = () => onGroupClick(group);
|
li.onclick = () => onGroupClick(group);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 2. 카테고리 동기화 (Pinned Categories)
|
||||||
|
import('./components/SidebarUI.js').then(({ renderCategoryList }) => {
|
||||||
|
const categoryNav = document.getElementById('categoryNav');
|
||||||
|
|
||||||
|
// 💡 settingsCache가 비어있을 경우 ThemeManager에서 직접 복구 시도
|
||||||
|
const pinned = settingsCache.pinned_categories || (ThemeManager.settings ? ThemeManager.settings.pinned_categories : []);
|
||||||
|
|
||||||
|
renderCategoryList(categoryNav, pinned, activeCategory, onCategoryClick);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 기능 활성화 여부에 따라 UI 요소 노출 제어
|
||||||
|
*/
|
||||||
|
applyCategoryVisibility(enabled) {
|
||||||
|
const composerBar = document.getElementById('composerCategoryBar');
|
||||||
|
const sidebarSection = document.getElementById('categorySidebarSection');
|
||||||
|
|
||||||
|
if (composerBar) {
|
||||||
|
// 작성기 칩 영역은 가로 정렬을 위해 flex 레이아웃이 필수입니다.
|
||||||
|
composerBar.style.display = enabled ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
if (sidebarSection) {
|
||||||
|
// 사이드바 섹션은 기본 블록 레이아웃을 사용합니다.
|
||||||
|
sidebarSection.style.display = enabled ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Category UI visibility updated: ${enabled ? 'VISIBLE' : 'HIDDEN'}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 캐시 업데이트 (내부용)
|
||||||
|
*/
|
||||||
|
_updateSettingsCache(settings) {
|
||||||
|
settingsCache = settings;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -201,6 +241,9 @@ export const UI = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 전역 동기화를 위해 window 객체에 할당
|
||||||
|
window.UI = UI;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 전역 파일 다운로드 함수 (항상 전역 스코프 유지)
|
* 전역 파일 다운로드 함수 (항상 전역 스코프 유지)
|
||||||
*/
|
*/
|
||||||
|
|||||||
+12
-1
@@ -11,6 +11,8 @@
|
|||||||
"nav_logout": "Logout",
|
"nav_logout": "Logout",
|
||||||
"nav_settings": "Settings",
|
"nav_settings": "Settings",
|
||||||
"nav_toggle": "Toggle Sidebar",
|
"nav_toggle": "Toggle Sidebar",
|
||||||
|
"nav_categories": "Categories",
|
||||||
|
"nav_category_manage": "Manage Categories",
|
||||||
|
|
||||||
"search_placeholder": "Search memos... (Title, Content, Tag)",
|
"search_placeholder": "Search memos... (Title, Content, Tag)",
|
||||||
"composer_placeholder": "Leave a fragment of knowledge...",
|
"composer_placeholder": "Leave a fragment of knowledge...",
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
"settings_security": "Security Border Color",
|
"settings_security": "Security Border Color",
|
||||||
"settings_ai_accent": "AI Accent Color",
|
"settings_ai_accent": "AI Accent Color",
|
||||||
"settings_ai_enable": "Enable AI Features",
|
"settings_ai_enable": "Enable AI Features",
|
||||||
|
"settings_category_enable": "Enable Category Feature (Advanced)",
|
||||||
"settings_lang": "Language",
|
"settings_lang": "Language",
|
||||||
"settings_save": "Save Settings",
|
"settings_save": "Save Settings",
|
||||||
"settings_reset": "Reset",
|
"settings_reset": "Reset",
|
||||||
@@ -90,6 +93,12 @@
|
|||||||
"label_group_explorer": "📁 Group Explorer",
|
"label_group_explorer": "📁 Group Explorer",
|
||||||
"label_tag_explorer": "🏷️ Tag Explorer",
|
"label_tag_explorer": "🏷️ Tag Explorer",
|
||||||
"label_last_updated": "Last updated: ",
|
"label_last_updated": "Last updated: ",
|
||||||
|
"label_category_done": "Done",
|
||||||
|
"label_no_category": "No Category",
|
||||||
|
"tooltip_add_category": "Add/Edit Category",
|
||||||
|
"prompt_category_name": "Enter category name (max 10 chars):",
|
||||||
|
"msg_confirm_delete_category": "Delete this category?",
|
||||||
|
"msg_category_limit": "Maximum 3 categories can be pinned.",
|
||||||
|
|
||||||
"shortcuts_label": "⌨️ Shortcuts",
|
"shortcuts_label": "⌨️ Shortcuts",
|
||||||
"shortcut_save": "Save",
|
"shortcut_save": "Save",
|
||||||
@@ -109,7 +118,9 @@
|
|||||||
"h2": "Heading 2",
|
"h2": "Heading 2",
|
||||||
"h3": "Heading 3",
|
"h3": "Heading 3",
|
||||||
"ai_summary": "AI Summary",
|
"ai_summary": "AI Summary",
|
||||||
"ai_tags": "AI Tags"
|
"ai_tags": "AI Tags",
|
||||||
|
"shortcut_cat_3": "Alt+4: %s",
|
||||||
|
"shortcut_cat_clear": "Alt+5: Clear Category"
|
||||||
},
|
},
|
||||||
|
|
||||||
"calendar_months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
|
"calendar_months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
"nav_logout": "로그아웃",
|
"nav_logout": "로그아웃",
|
||||||
"nav_settings": "환경 설정",
|
"nav_settings": "환경 설정",
|
||||||
"nav_toggle": "사이드바 토글",
|
"nav_toggle": "사이드바 토글",
|
||||||
|
"nav_categories": "카테고리",
|
||||||
|
"nav_category_manage": "카테고리 관리",
|
||||||
|
|
||||||
"search_placeholder": "메모 검색... (제목, 내용, 태그)",
|
"search_placeholder": "메모 검색... (제목, 내용, 태그)",
|
||||||
"composer_placeholder": "지식의 파편을 남겨주세요...",
|
"composer_placeholder": "지식의 파편을 남겨주세요...",
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
"settings_security": "보안 테두리색",
|
"settings_security": "보안 테두리색",
|
||||||
"settings_ai_accent": "AI 분석 강조색",
|
"settings_ai_accent": "AI 분석 강조색",
|
||||||
"settings_ai_enable": "AI 기능 활성화",
|
"settings_ai_enable": "AI 기능 활성화",
|
||||||
|
"settings_category_enable": "카테고리 기능 활성화 (고급)",
|
||||||
"settings_lang": "언어 설정",
|
"settings_lang": "언어 설정",
|
||||||
"settings_save": "저장",
|
"settings_save": "저장",
|
||||||
"settings_reset": "초기화",
|
"settings_reset": "초기화",
|
||||||
@@ -89,6 +92,12 @@
|
|||||||
"label_group_explorer": "📁 그룹별 탐색",
|
"label_group_explorer": "📁 그룹별 탐색",
|
||||||
"label_tag_explorer": "🏷️ 태그별 탐색",
|
"label_tag_explorer": "🏷️ 태그별 탐색",
|
||||||
"label_last_updated": "마지막 수정: ",
|
"label_last_updated": "마지막 수정: ",
|
||||||
|
"label_category_done": "완료",
|
||||||
|
"label_no_category": "카테고리 없음",
|
||||||
|
"tooltip_add_category": "카테고리 추가/편집",
|
||||||
|
"prompt_category_name": "새 카테고리 이름을 입력하세요 (최대 10자):",
|
||||||
|
"msg_confirm_delete_category": "이 카테고리를 삭제할까요?",
|
||||||
|
"msg_category_limit": "카테고리는 최대 3개까지만 핀(Pin) 고정 가능합니다.",
|
||||||
|
|
||||||
"shortcuts_label": "⌨️ 단축키",
|
"shortcuts_label": "⌨️ 단축키",
|
||||||
"shortcut_save": "저장",
|
"shortcut_save": "저장",
|
||||||
@@ -96,6 +105,8 @@
|
|||||||
"shortcut_nebula": "네뷸라",
|
"shortcut_nebula": "네뷸라",
|
||||||
"shortcut_slash": "슬래시 명령",
|
"shortcut_slash": "슬래시 명령",
|
||||||
"shortcut_edit": "즉시 수정",
|
"shortcut_edit": "즉시 수정",
|
||||||
|
"shortcut_cat_3": "Alt+4: %s",
|
||||||
|
"shortcut_cat_clear": "Alt+5: 분류 해제",
|
||||||
|
|
||||||
"slash": {
|
"slash": {
|
||||||
"task": "체크박스",
|
"task": "체크박스",
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Category Slots (Quick Assign) -->
|
||||||
|
<div id="composerCategoryBar">
|
||||||
|
<!-- JS will render category chips here -->
|
||||||
|
</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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<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" id="categorySidebarSection">
|
||||||
|
<div class="section-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||||
|
<span class="section-title" data-i18n="nav_categories">Categories</span>
|
||||||
|
<button id="manageCategoryBtn" class="action-btn" style="padding: 2px 8px; font-size: 0.75rem;" data-i18n-title="tooltip_add_category">⚙️</button>
|
||||||
|
</div>
|
||||||
|
<ul class="nav" id="categoryNav">
|
||||||
|
<!-- JS will render pinned categories here -->
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<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>
|
||||||
+10
-183
@@ -26,193 +26,20 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<aside class="sidebar" id="sidebar">
|
{% include 'components/sidebar.html' %}
|
||||||
<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">
|
<main class="content">
|
||||||
<div class="topbar">
|
{% include 'components/topbar.html' %}
|
||||||
<button id="mobileMenuBtn" class="sidebar-toggle" style="display: none; margin-right: 15px;">☰</button>
|
{% include 'components/composer.html' %}
|
||||||
<div class="search-bar">
|
{% include 'components/memo_grid.html' %}
|
||||||
<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>
|
</main>
|
||||||
|
|
||||||
<!-- Modal for viewing memo details/links -->
|
{% include 'modals/memo_detail.html' %}
|
||||||
<div id="memoModal" class="modal">
|
{% include 'modals/settings.html' %}
|
||||||
<div class="modal-content glass-panel" id="modalContent"></div>
|
{% include 'modals/category.html' %}
|
||||||
</div>
|
{% include 'modals/graph.html' %}
|
||||||
|
{% include 'modals/explorer.html' %}
|
||||||
<!-- Settings Modal -->
|
{% include 'modals/overlays.html' %}
|
||||||
<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>
|
<script type="module" src="/static/app.js?v=2.2"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!-- Category Management Modal -->
|
||||||
|
<div id="categoryModal" class="modal">
|
||||||
|
<div class="modal-content glass-panel" style="max-width: 420px; padding: 18px 22px; background: var(--bg); border: 1px solid rgba(255,255,255,0.1);">
|
||||||
|
<div class="modal-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 12px;">
|
||||||
|
<h2 style="margin: 0; font-size: 1.15rem; color: white;" data-i18n="nav_category_manage">Manage Categories</h2>
|
||||||
|
<button id="closeCategoryBtn" style="background: none; border: none; color: var(--muted); font-size: 1.35rem; cursor: pointer; line-height: 1;">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div style="display: flex; gap: 8px; margin-bottom: 15px;">
|
||||||
|
<input type="text" id="newCategoryInput" data-i18n-placeholder="prompt_category_name"
|
||||||
|
style="flex: 1; padding: 10px; border-radius: 8px; background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.1); color: white; outline: none; font-size: 0.9rem;">
|
||||||
|
<button id="addCategoryBtn" class="primary-btn" style="padding: 0 15px; height: 38px;">+</button>
|
||||||
|
</div>
|
||||||
|
<div id="categoryListContainer" style="max-height: 320px; overflow-y: auto; padding-right: 5px;">
|
||||||
|
<!-- JS will render category list here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<!-- 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>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<!-- 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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<!-- Modal for viewing memo details/links -->
|
||||||
|
<div id="memoModal" class="modal">
|
||||||
|
<div class="modal-content glass-panel" id="modalContent"></div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<!-- 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>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!-- 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;">
|
||||||
|
|
||||||
|
<label style="font-weight: 800; color: #38bdf8;" data-i18n="settings_category_enable">카테고리 기능 활성화 (고급)</label>
|
||||||
|
<input type="checkbox" id="set-enable-categories" 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>
|
||||||
Reference in New Issue
Block a user