v1.5: Integrated optional category feature, i18n stabilization, and documentation update

This commit is contained in:
leeyj
2026-04-16 15:42:02 +09:00
parent df8ae62b0e
commit aef0179c56
47 changed files with 1699 additions and 544 deletions
+7
View File
@@ -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
View File
@@ -1,10 +1,34 @@
# Brain Dogfood Public Repo Gitignore
# [뇌사료] Git 배포용 .gitignore
# Python
__pycache__/
*.py[cod]
*$py.class
.venv/
build/
dist/
# Configuration & Secrets
.env
config.json
memos.db
__pycache__/
*.pyc
logs/
# Data & Databases
data/
.vscode/
*.db
*.sqlite3
# Media & Uploads
static/uploads/*
!static/uploads/.gitkeep
# Logs & Temporary
logs/
*.log
scratch/
.gemini/
.idea/
.vscode/
# System
.DS_Store
Thumbs.db
+1 -1
View File
@@ -78,7 +78,7 @@ def create_app():
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
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
)
+2 -1
View File
@@ -5,8 +5,9 @@ from flask import session, redirect, url_for, request, current_app # type: ignor
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')
return username == admin_user and password == admin_password
+5
View File
@@ -37,6 +37,11 @@ def init_db():
except sqlite3.OperationalError:
pass
try:
c.execute("ALTER TABLE memos ADD COLUMN category TEXT")
except sqlite3.OperationalError:
pass
# 2. Separate Tags Table (Normalized)
c.execute('''
+4 -1
View File
@@ -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 ..utils.i18n import _t
@@ -13,7 +13,10 @@ def login():
if check_auth(username, password):
session.permanent = True # Enable permanent session to use LIFETIME config
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'})
current_app.logger.warning(f"AUTH: Failed login attempt for user '{username}' from {request.remote_addr}")
return jsonify({'error': _t('msg_auth_failed')}), 401
@auth_bp.route('/logout')
+3 -2
View File
@@ -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
import os
import json
@@ -22,6 +22,7 @@ def login_page():
try:
with open(config_path, 'r', encoding='utf-8') as f:
lang = json.load(f).get('lang', 'ko')
except: pass
except Exception:
pass
return render_template('login.html', lang=lang)
+44 -10
View File
@@ -4,7 +4,7 @@ from ..database import get_db
from ..auth import login_required
from ..constants import GROUP_DONE, GROUP_DEFAULT
from ..utils.i18n import _t
from ..utils import extract_links
from ..utils import extract_links, parse_metadata, parse_and_clean_metadata, generate_auto_title
from ..security import encrypt_content, decrypt_content
memo_bp = Blueprint('memo', __name__)
@@ -17,8 +17,11 @@ def get_memos():
group = request.args.get('group', 'all')
query = request.args.get('query', '')
date = request.args.get('date', '')
category = request.args.get('category')
if date in ('null', 'undefined'):
date = ''
if category in ('null', 'undefined'):
category = ''
conn = get_db()
c = conn.cursor()
@@ -52,8 +55,13 @@ def get_memos():
where_clauses.append("created_at LIKE ?")
params.append(f"{date}%")
# 4. 초기 로드 시 최근 5일 강조 (필터가 없는 경우에만 적용)
if offset == 0 and group == 'all' and not query and not date:
# 4. 카테고리 필터링
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()
where_clauses.append("(updated_at >= ? OR is_pinned = 1)")
params.append(start_date)
@@ -155,6 +163,17 @@ def create_memo():
user_tags = data.get('tags', [])
is_encrypted = 1 if data.get('is_encrypted') else 0
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:
content = encrypt_content(content, password)
@@ -169,9 +188,9 @@ def create_memo():
c = conn.cursor()
try:
c.execute('''
INSERT INTO memos (title, content, color, is_pinned, status, group_name, is_encrypted, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (title, content, color, is_pinned, status, group_name, is_encrypted, now, now))
INSERT INTO memos (title, content, color, is_pinned, status, group_name, category, is_encrypted, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (title, content, color, is_pinned, status, group_name, category, is_encrypted, now, now))
memo_id = c.lastrowid
for tag in user_tags:
@@ -208,26 +227,39 @@ def update_memo(memo_id):
user_tags = data.get('tags')
is_encrypted = data.get('is_encrypted')
password = data.get('password', '').strip()
category = data.get('category')
now = datetime.datetime.now().isoformat()
conn = get_db()
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()
if memo and memo['is_encrypted']:
# 암호화된 메모지만 '암호화 해제(is_encrypted=0)' 요청이 온 경우는
# 비밀번호 없이도 수정을 시도할 수 있어야 할까? (아니오, 인증이 필요함)
if not password:
conn.close()
return jsonify({'error': _t('msg_encrypted_locked')}), 403
# 비밀번호가 맞는지 검증 (복호화 시도)
if decrypt_content(memo['content'], password) is None:
conn.close()
return jsonify({'error': _t('msg_auth_failed')}), 403
# 본문 기반 메타데이터 통합 및 정리 ($그룹, #태그 하단 이동)
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:
updates = ['updated_at = ?']
params = [now]
@@ -252,6 +284,8 @@ def update_memo(memo_id):
updates.append('group_name = ?'); params.append(group_name.strip() or GROUP_DEFAULT)
if is_encrypted is not None:
updates.append('is_encrypted = ?'); params.append(1 if is_encrypted else 0)
if category is not None:
updates.append('category = ?'); params.append(category)
params.append(memo_id)
c.execute(f"UPDATE memos SET {', '.join(updates)} WHERE id = ?", params)
+4 -1
View File
@@ -15,7 +15,10 @@ DEFAULT_SETTINGS = {
"encrypted_border": "#00f3ff",
"ai_accent": "#8b5cf6",
"enable_ai": True,
"lang": "ko"
"lang": "ko",
"enable_categories": False, # 카테고리 기능 활성화 여부 (고급 옵션)
"categories": [], # 무제한 전체 목록
"pinned_categories": [] # 최대 3개 (Alt+2~4 할당용)
}
@settings_bp.route('/api/settings', methods=['GET'])
+70 -5
View File
@@ -1,26 +1,91 @@
import re
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 = []
if not text:
return group_name, tags
group_match = re.search(r'##(\S+)', text)
# $그룹명 추출 (단어 경계 고려, 첫 번째 매칭만)
group_match = re.search(r'\$(\w+)', text)
if group_match:
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:
tags.append(match.group(1))
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):
"""
텍스트에서 [[#ID]] 형태의 내부 링크를 찾아 ID 목록(정수)을 반환합니다.
-17
View File
@@ -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)
-21
View File
@@ -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
View File
@@ -1,4 +1,4 @@
# 🧠 뇌사료 (Brain Dogfood) 프로젝트 문서화 (v5.0+)
# 🧠 뇌사료 (Brain Dogfood) 프로젝트 문서화 (v1.5)
> **"지식은 기록될 때 힘을 얻고, 연결될 때 생명을 얻는다."**
@@ -12,10 +12,11 @@
- **이중 보안**: 메모별 개별 암호화 및 미디어 보안 실드.
- **AI 구조화**: Gemini 2.0 Flash 기반 자동 요약 및 지능형 태깅.
## ✨ What's New in v5.0
- **Heatmap**: 최근 1년/6개월/3개월/1개월 활동량 지표 추가.
- **Performance**: 대량 데이터 로딩 최적화(Bulk Fetch) 및 무한 스크롤 도입.
- **Editor**: 중요 지식 강조를 위한 글자 색상(Color Syntax) 기능 통합.
## ✨ What's New in v1.5
- **Advanced Category Toggle**: 라이트 유저를 위해 카테고리 기능을 숨기거나 켤 수 있는 옵션 도입.
- **i18n Stabilization**: 한국어/영어 전환 시 히트맵, 달력 등 동적 컴포넌트까지 완벽하게 자가 교정 및 번역 반영.
- **V5 Metadata Shield**: 정규식 엔진 고도화를 통해 시스템 메타데이터 중복 및 푸터 손상 원천 차단.
- **Data Integrity**: 다국어 환경의 정합성을 위해 내부 그룹명을 영문 상수로 통일.
## 📂 문서 인덱스
1. [**사용자 매뉴얼 (User Manual)**](user_manual.md): **[최초 사용자 필독]** 사용법 및 연결 문법
@@ -27,4 +28,4 @@
7. [**단축키 가이드 (Shortcuts Guide)**](shortcuts.md): **[업무 효율 극대화]** 탐색 및 편집 단축키 총정리
---
*Last Updated: 2026-04-15*
*Last Updated: 2026-04-16 (v1.5 Milestone Release)*
+39 -19
View File
@@ -1,4 +1,4 @@
# 📡 데이터베이스 및 API 명세서 (v13.5)
# 📡 데이터베이스 및 API 명세서 (v1.5)
본 문서는 `뇌사료` 프로젝트의 데이터 저장 구조(Schema)와 모든 외부 통신 인터페이스(API)를 상세히 기술합니다.
@@ -10,10 +10,36 @@
| :--- | :--- | :--- | :--- |
| `id` | INTEGER | PRIMARY KEY | 자동 증가 고유 아이디 |
| `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 | 암호화 여부 |
| `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]]` 링크 및 시각화 인력을 관리합니다.
| 컬럼명 | 타입 | 설명 |
| :--- | :--- | :--- |
@@ -22,23 +48,17 @@
---
## 🌐 2. API 엔드포인트 전수 명세
## 🌐 2. API 엔드포인트 명세 (주요 항목)
### 2.1 Memos & Analysis
| Method | URL | Description |
| :--- | :--- | :--- |
| `GET` | `/api/memos` | 전체 메모 목록, 태그, 첨부파일, **백링크** 정보 통합 조회 |
| `POST` | `/api/memos/<id>/decrypt` | 비밀번호 검증 및 본문 일시 복호화 |
| `GET` | `/api/stats/heatmap` | 최근 N일간의 일자별 메모 작성 수(통계) 조회 (`days` 파라미터 지원) |
### 2.1 Memos & Search
- `GET /api/memos`: 필터링된 메모 목록 및 메타데이터 통합 조회.
- `POST /api/memos/<id>/decrypt`: 암호화된 메모 복호화 요청.
- `GET /api/stats/heatmap`: 히트맵 렌더링을 위한 통계 데이터 조회.
### 2.2 Assets (제한적 접근)
| Method | URL | Security Policy | Description |
### 2.2 Settings & Configuration (v1.5 업데이트)
| Method | URL | Parameters | Description |
| :--- | :--- | :--- | :--- |
| `GET` | `/api/download/<filename>` | **세션 필수(로그인 상호작용)** | 이미지/파일 다운로드. 이미지인 경우 `inline` 처리 및 암호화 메모 관련 파일은 로그인 미달 시 403 차단. |
| `POST` | `/api/upload` | `login_required` | 파일 업로드 및 서버 측 마스터 키 암호화 저장. |
| `GET` | `/api/settings` | - | 테마, 언어, 고급 기능 활성화 상태 조회 |
| `POST` | `/api/settings` | `lang`, `enable_categories`, `bg_color` 등 | 서버 설정을 영구 업데이트 |
### 2.3 Settings & Ops (v11.0 추가)
| Method | URL | Description |
| :--- | :--- | :--- |
| `GET` | `/api/settings` | 서버 사이드 테마 및 전역 설정 조회 |
| `POST` | `/api/settings` | UI 테마 설정을 서버에 영구 기록 |
> **v1.5 변경점**: `lang`(언어), `enable_categories`(고급 카테고리 사용 여부) 필드가 추가되었습니다.
+13 -15
View File
@@ -1,4 +1,4 @@
# 🏢 시스템 아키텍처 및 폴더 구조 (v5.0+)
# 🏢 시스템 아키텍처 및 폴더 구조 (v1.5)
본 문서는 `뇌사료` 프로젝트의 물리적 파일 구조와 논리적 설계 아키텍처를 상세히 기술합니다.
@@ -11,11 +11,9 @@
| `/data` | **Database Box** | SQLite3 DB 파일 (`memos.db`) 저장 위치 |
| `/docs` | **Documentation** | 시스템 기술 문서 및 가이드 |
| `/logs` | **Log Box** | 시스템 작동 및 접근 로그 (`app.log`) |
| `/static` | **Static Assets** | CSS, 이미지, 파비코 및 프론트엔드 JS |
| `/static` | **Static Assets** | CSS, 이미지 및 프론트엔드 리소스 |
| `/static/js/components` | **UI Components** | D3.js 시각화 모듈 및 UI 핵심 로직 |
| `/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
- **패키지 구조**: `app/__init__.py`에서 중앙 집중식으로 앱을 생성하고, `routes/` 아래의 각 기능을 Blueprint로 등록합니다.
- **보안 실드 (Security Shield)**: `before_request` 단계에서 비정상적인 트래픽 및 파라미터를 필터링하는 로깅 시스템이 선제적으로 작동합니다.
- **성능 최적화 (Bulk Fetch)**: 다량의 메모리 조회 시 발생하는 N+1 문제를 방지하기 위해 태그, 첨부파일, 백링크 정보를 한꺼번에 Fetch하는 벌크 조회 로직이 적용되었습니다.
- **다국어 엔진 (v1.5)**: 서버 사이드에서도 `i18n.py`를 통해 클라이언트 언어 환경에 맞춤화된 응답(에러 메시지 등)을 제공합니다.
### 2.2 Frontend: Modular Component Architecture
- **지식 네뷸라 (Knowledge Nebula)**: D3.js의 물리 시뮬레이션 엔진을 도입하여 유기적인 성단 구조를 시각화합니다.
- **컴포넌트 중심 설계**: `HeatmapManager.js` (활동 시각화), `CalendarManager.js` (달력), `Visualizer.js` (그래프), `DrawerManager.js` (탐색기) 등으로 독립된 모듈 구조를 채택하여 유지보수성을 극대화했습니다.
- **레이아웃 혁명**: **무한 스크롤(Infinite Scroll)** 페이징 기법을 도입하여 수천 개의 지식 파편도 성능 저하 없이 탐색할 수 있습니다.
- **State Management**: `AppService.js`를 중앙 상태 관리 엔진으로 활용하여 데이터 요청과 UI 업데이트의 정합성을 유지합니다.
### 2.2 Frontend: State-Driven UI
- **컴포넌트 중심 설계**: `HeatmapManager.js`, `CalendarManager.js`, `ComposerCategoryUI.js` 등으로 독립된 모듈 구조를 채택했습니다.
- **State Management**: `AppService.js`를 통해 전역 상태를 관리하며, 설정 변경(언어, 테마) 시 `ThemeManager.js`가 시스템 전반의 정합성을 동기화합니다.
### 2.3 Ops & Reliability
- **Merged Configuration**: 개발/운영 환경의 환경변수를 한곳에서 관리하며, 배포 시 `.env` 파일을 통해 보안 설정이 주입됩니다.
- **Surgical Cleanup**: 배포 시 운영 데이터(DB, Uploads)는 보존하고 코드 영역만 정밀하게 교체하는 수술적 배포 방식을 채택했습니다.
- **Disaster Recovery**: `backup.py`를 통해 서버 침해나 시스템 붕괴 시에도 3대 핵심 자산(.env, DB, Uploads)만으로 즉시 복구가 가능한 구조를 갖췄습니다.
### 2.3 Data Policy: English Constant Policy (v1.5 정책)
- **데이터 정합성**: 다국어 환경에서 그룹 필터링 등이 오작동하는 것을 방지하기 위해, 데이터베이스의 `group_name` 필드에는 **영문 상수**(`default`, `files`, `done` 등)를 저장하는 것을 원칙으로 합니다.
- **매핑 방식**: 화면에 노출되는 텍스트는 프론트엔드 i18n 매니저를 통해 사용자의 현재 언어 설정에 맞춰 동적으로 번역되어 표기됩니다.
### 2.4 Ops & Reliability
- **Surgical Cleanup**: 배포 시 운영 데이터(DB, Uploads)는 보존하고 코드 영역만 정밀하게 교체하는 방식을 채택했습니다.
- **Disaster Recovery**: `backup.py`를 통해 핵심 자산(.env, DB, Uploads)을 증분 백업하여 언제든 즉시 복구가 가능합니다.
+15 -20
View File
@@ -1,6 +1,6 @@
# 💎 핵심 기능 가이드 (v13.3)
# 💎 핵심 기능 가이드 (v1.5)
본 문서는 `뇌사료` 프로젝트를 상징하는 핵심 기능들인 **지식 시각화**, **암호화**, **AI 분석**에 대한 상세 명세를 담고 있습니다.
본 문서는 `뇌사료` 프로젝트를 상징하는 핵심 기능들인 **지식 시각화**, **암호화**, **AI 분석** 및 **고객 맞춤형 설정**에 대한 상세 명세를 담고 있습니다.
## 🌌 1. 지식 네뷸라 (Knowledge Nebula)
D3.js v7 물리 시뮬레이션 엔진을 통해 파편화된 메모들을 유기적인 우주 성단 구조로 시각화합니다.
@@ -16,7 +16,7 @@ D3.js v7 물리 시뮬레이션 엔진을 통해 파편화된 메모들을 유
### 2.1 메모 및 파일 보안
- **개별 암호화**: 메모마다 고유한 비밀번호를 사용하여 `Fernet (AES-128 CBC/HMAC)` 방식으로 본문을 암호화합니다.
- **미디어 실드 (v10.1)**: 모든 첨부파일은 서버 마스터 키로 암호화되어 저장됩니다. 암호화된 메모의 이미지는 **로그인된 세션**에서만 정밀하게 렌더링을 허용하여 데이터 유출을 원천 차단합니다.
- **미디어 실드**: 모든 첨부파일은 서버 마스터 키로 암호화되어 저장됩니다. 암호화된 메모의 이미지는 **로그인된 세션**에서만 정밀하게 렌더링을 허용하여 데이터 유출을 원천 차단합니다.
## 🧠 3. Gemini AI 기반 지식 구조화 (AI Insight)
@@ -29,20 +29,15 @@ D3.js v7 물리 시뮬레이션 엔진을 통해 파편화된 메모들을 유
### 4.1 연결 문법 (`[[#ID]]`)
- **자동 링크**: 본문에 `[[#12]]`와 같이 입력하면 뷰어에서 클릭 가능한 링크로 변환되며, 지식 맵 상에서 두 노드 사이에 **강력한 실선**이 형성됩니다.
- **역방향 추적 (Backlinks)**: 특정 메모 카드 하단에 해당 메모를 인용 중인 다른 메모의 목록이 노출되어, 지식의 흐름을 양방향으로 추적할 수 있습니다.
27:
32: ## 🌡️ 5. 지식 성장 히트맵 (Intellectual Growth Heatmap) - v14.0
33:
34: ### 5.1 활동 시각화
35: - **기록 습관 형성**: 최근 365일간의 활동량을 GitHub 스타일의 그리드로 시각화하여 지식 축적의 꾸준함을 독려합니다.
36: - **동적 범위 필터링**: 사용자의 필요에 따라 **1개월 / 3개월 / 6개월 / 1년** 단위를 자유롭게 선택하여 볼 수 있습니다.
37: - **상태 보존**: 선택한 보기 설정은 `localStorage`에 저장되어 재접속 시에도 유지됩니다.
38:
39: ### 5.2 지능형 히트맵 알고리즘
40: - **단계별 농도**: 해당 일의 메모 작성 수에 따라 5단계(`lvl-0`~`lvl-4`)의 색상 농도가 적용됩니다.
41: - **프리미엄 그라데이션**: 뇌사료 특유의 Cyan(시안)에서 Purple(보라)로 이어지는 네온 그라데이션 테마를 따릅니다.
42:
43: ## 🎨 6. 확장된 에디터 스타일링 (Enhanced Editor)
44:
45: ### 6.1 컬러 텍스트 (Color Syntax)
46: - **시각적 강조**: Toast UI Editor의 컬러 신택스 플러그인을 통합하여, 본문 중 중요한 지식 키워드를 다양한 색상으로 강조할 수 있습니다.
47: - **지각적 설계**: 다크 모드 환경에서도 가독성이 뛰어난 색상 팔레트를 우선적으로 제공합니다.
## 🌡️ 5. 지식 성장 히트맵 (Intellectual Growth Heatmap)
- **활동 시각화**: 최근 365일간의 활동량을 GitHub 스타일의 그리드로 시각화하여 지식 축적의 꾸준함을 독려합니다.
- **동적 범위 필터링**: 사용자의 필요에 따라 **1개월 / 3개월 / 6개월 / 1년** 단위를 자유롭게 선택하여 볼 수 있습니다.
## 🎨 6. 확장된 에디터 스타일링 (Enhanced Editor)
- **컬러 텍스트 (Color Syntax)**: Toast UI Editor의 컬러 신택스 플러그인을 통합하여, 중요 키워드를 다양한 색상으로 강조할 수 있습니다.
- **V5 메타데이터 쉴드**: 정밀한 정규식 엔진을 도입하여 메모 하단의 시스템 메타데이터가 중복되거나 파손되는 것을 방지하고 항상 깔끔한 상태를 유지합니다.
## ⚙️ 7. 맞춤형 사용자 환경 (v1.5 신규)
- **고급 설정 (Advanced Categories)**: 라이트 유저를 위해 복잡한 카테고리 기능을 숨길 수 있습니다. 설정에서 활성화 시에만 작성기 칩과 사이드바 섹션이 정밀한 레이아웃으로 노출됩니다.
- **글로벌 인텔리전스 (i18n Stabilization)**: 한국어와 영어를 완벽하게 지원하며, 언어 설정을 변경할 경우 히트맵과 달력 등 동적 컴포넌트까지 실시간으로(자동 새로고침) 완벽하게 번역이 적용됩니다.
+376
View File
@@ -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
View File
@@ -1,4 +1,4 @@
# 📔 뇌사료 (Brain Dogfood) 사용자 매뉴얼 (v5.0+)
# 📔 뇌사료 (Brain Dogfood) 사용자 매뉴얼 (v1.5)
'뇌사료' 프로젝트에 오신 것을 환영합니다! 본 매뉴얼은 파편화된 영감을 체계적인 지식 성단(Nebula)으로 구축하는 데 필요한 모든 가이드를 제공합니다.
@@ -8,105 +8,56 @@
메인 전면에 펼쳐진 **지식 네뷸라**는 단순한 목록이 아닌 지식의 유기적인 지도를 보여줍니다.
- **노드(Node)**: 각각의 메모를 상징니다.
- **크기**: 내용이 많거나 연결이 많을수록 노드가 거대해집니다.
- **색상**: 각 메모에 설정된 고유한 그룹 색상을 따릅니다.
- **🔒 아이콘**: 암호화된 메모임을 나타내며, 제목만 미리보기로 제공됩니다.
- **링크(Link)**:
- **실선**: `[[#ID]]` 문법으로 명시적으로 연결된 관계입니다.
- **인력(Gravity)**: 같은 그룹이나 공통 태그를 가진 메모들은 서로를 끌어당겨 가까이 배치됩니다.
- **노드(Node)**: 각각의 메모를 상징하며, 그룹 색상을 따릅니다.
- **링크(Link)**: `[[#ID]]` 문법으로 명시적으로 연결된 관계를 시선과 인력으로 표현합니다.
---
## 🌡️ 3. 지식 성장 히트맵 (Heatmap) 사용법
## 🌡️ 2. 지식 성장 히트맵 (Heatmap) 사용법
사이드바에 위치한 히트맵은 사용자의 기록 강도를 시각적으로 보여줍니다.
사이드바 히트맵은 최근 1년간의 지식 축적 활동량을 시각적으로 보여줍니다.
- **기간 전환**: 히트맵 상단 제목 옆의 드롭다운을 통해 **1개월 / 3개월 / 6개월 / 1년** 단위를 선택할 수 있습니다.
- **상태 유지**: 한 번 선택한 기간은 브라우저에 저장되어 다음 접속 시에도 그대로 유지됩니다.
- **활동량 확인**: 각 칸에 마우스를 올리면 해당 날짜에 작성된 메모의 개수를 확인할 수 있습니다. 색상이 진해질수록(Cyan -> Purple) 더 많은 지식을 축적했음을 의미합니다.
- **기간 전환**: 상단 드롭다운을 통해 **1개월 / 3개월 / 6개월 / 1년** 단위를 선택할 수 있습니다.
- **언어 연동**: 시스템 언어 설정에 따라 제목과 범례("Less"/"More")가 자동으로 번역됩니다.
---
## ✍️ 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 키 설정 (초보자 가이드)
'뇌사료'의 지능형 기능을 사용하려면 Google의 Gemini API 키가 필요합니다. 다음 단계에 따라 **1분 만에 무료로** 설정을 마칠 수 있습니다.
### 4.1 고급 설정 (카테고리 활성화)
- **라이트 모드 (기본)**: 카테고리 기능이 숨겨져 있어 태그와 그룹만으로 심플하게 기록할 수 있습니다.
- **고급 모드**: 설정에서 **"카테고리 기능(고급) 활성화"**를 체크하면, 작성기 하단에 카테고리 선택 칩이 나타나며 사이드바에서도 카테고리별 분류가 활성화됩니다.
1. **키 발급**: [Google AI Studio (https://aistudio.google.com/app/apikey)](https://aistudio.google.com/app/apikey)에 접속합니다.
2. **프로젝트 생성**: "Create API key in new project" 버튼을 누릅니다.
3. **키 복사**: 생성된 `AIza...`로 시작하는 긴 문자열을 복사합니다.
4. **서버 적용**: 본 프로젝트 폴더의 `.env` 파일을 열고 `GEMINI_API_KEY=` 뒤에 복사한 키를 붙여넣고 저장합니다.
5. **활성화**: 앱 우상단 **[⚙️ 설정]** -> **AI 기능 활성화** 체크박스를 켜고 저장합니다.
> [!TIP]
> - API 키 발급은 완전히 무료이며, 개인적인 용도로는 충분한 할당량이 제공됩니다.
> - 키가 없더라도 메모 작성 및 시각화 등 기본적인 기능은 "NO AI" 모드로 완벽하게 작동합니다.
### 5.2 주요 기능
- **자동 요약**: 방대한 내용을 AI가 1~2문장의 핵심 문장으로 압축해줍니다.
- **스마트 태그**: 본문을 분석하여 자동으로 추천 태그를 생성합니다.
- **추론형 배치**: AI가 생성한 태그를 기반으로 지식 네뷸라 상에서 비슷한 맥락의 메모들이 자동으로 성단을 형성합니다.
### 4.2 다국어 설정 (Language)
- **한국어 / English**: 선호하는 언어를 선택하고 [Save]를 누르면 즉시 전체 UI와 달력, 히트맵 등의 도구가 해당 언어로 재초기화됩니다.
---
---
## ⌨️ 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 + 클릭`**: 메인 그리드에서 메모를 즉시 수정 ✏️
## 🔒 5. 보안 및 백업
- **개별 암호화**: 편집기 하단 [암호화 사용]을 통해 메모를 안전하게 잠글 수 있습니다.
- **미디어 보안**: 모든 첨부파일은 서버 측 암호화를 통해 세션이 유효한 사용자에게만 안전하게 제공됩니다.
---
## 🚀 7. 운영 및 관리 (Ops & Backup)
### 7.1 정밀 배포 (`deploy.py`)
개발 환경에서 작업한 코드를 서버로 안전하게 배포합니다.
```bash
python deploy.py
```
### 7.2 재난 복구 백업 (`backup.py`)
서버의 모든 핵심 데이터를 압축하여 안전하게 보관합니다.
```bash
python backup.py
```
## 🚀 6. 운영 및 관리 (Ops)
- **`deploy.py`**: 개발 환경의 코드를 서버로 안전하게 배포합니다.
- **`backup.py`**: 서버의 핵심 데이터를 정기적으로 백업합니다.
---
-49
View File
@@ -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*
+76
View File
@@ -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
View File
@@ -10,6 +10,7 @@ import { CalendarManager } from './js/components/CalendarManager.js';
import { Visualizer } from './js/components/Visualizer.js';
import { HeatmapManager } from './js/components/HeatmapManager.js';
import { DrawerManager } from './js/components/DrawerManager.js';
import { CategoryManager } from './js/components/CategoryManager.js';
import { ModalManager } from './js/components/ModalManager.js';
import { I18nManager } from './js/utils/I18nManager.js';
import { Constants } from './js/utils/Constants.js';
@@ -27,17 +28,20 @@ document.addEventListener('DOMContentLoaded', async () => {
AppService.setFilter({ date }, updateSidebarCallback);
});
DrawerManager.init();
CategoryManager.init(() => AppService.refreshData(updateSidebarCallback));
Visualizer.init('graphContainer');
UI.initSidebarToggle();
// --- 🔹 Callbacks ---
const updateSidebarCallback = (memos, activeGroup) => {
UI.updateSidebar(memos, activeGroup, (newFilter) => {
const updateSidebarCallback = (memos, activeGroup, activeCategory) => {
UI.updateSidebar(memos, activeGroup, activeCategory, (newFilter) => {
if (newFilter === Constants.GROUPS.FILES) {
ModalManager.openAssetLibrary((id, ms) => UI.openMemoModal(id, ms));
} else {
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) ---
document.addEventListener('keydown', (e) => {
const isCtrl = e.ctrlKey || e.metaKey;
@@ -189,6 +200,16 @@ document.addEventListener('DOMContentLoaded', async () => {
if (isAlt && key === '`') {
e.preventDefault();
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);
}
}
});
+152
View File
@@ -105,3 +105,155 @@
.action-btn:hover { background: rgba(184, 59, 94, 0.8); }
.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);
}
+23 -8
View File
@@ -10,6 +10,7 @@ export const AppService = {
state: {
memosCache: [],
currentFilterGroup: 'all',
currentFilterCategory: null, // NEW: 카테고리 필터
currentFilterDate: null,
currentSearchQuery: '',
offset: 0,
@@ -42,11 +43,11 @@ export const AppService = {
if (this.state.isLoading || !this.state.hasMore) return;
this.state.isLoading = true;
// UI.showLoading(true)는 호출부에서 관리하거나 여기서 직접 호출 가능
try {
const filters = {
group: this.state.currentFilterGroup,
category: this.state.currentFilterCategory, // NEW
date: this.state.currentFilterDate,
query: this.state.currentSearchQuery,
offset: this.state.offset,
@@ -68,13 +69,10 @@ export const AppService = {
this.state.offset += newMemos.length;
// 캘린더 점 표시는 첫 로드 시에면 하면 부족할 수 있으므로,
// 필요 시 전체 데이터를 새로 고침하는 별도 API가 필요할 수 있음.
// 여기서는 현재 캐시된 데이터 기반으로 업데이트.
CalendarManager.updateMemoDates(this.state.memosCache);
if (onUpdateSidebar) {
onUpdateSidebar(this.state.memosCache, this.state.currentFilterGroup);
onUpdateSidebar(this.state.memosCache, this.state.currentFilterGroup, this.state.currentFilterCategory);
}
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;
if (group !== undefined && 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;
}
if (date !== undefined && this.state.currentFilterDate !== date) {
this.state.currentFilterDate = date;
changed = true;
// UI 동기화
CalendarManager.setSelectedDate(date);
if (HeatmapManager.setSelectedDate) {
HeatmapManager.setSelectedDate(date);
+14 -4
View File
@@ -18,12 +18,22 @@ export const API = {
async fetchMemos(filters = {}) {
const { limit = 20, offset = 0, group = 'all', query = '' } = filters;
const date = filters.date || ''; // null이나 undefined를 빈 문자열로 변환
const params = new URLSearchParams({ limit, offset, group, query, date });
const date = filters.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()}`);
},
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) {
@@ -52,7 +62,7 @@ export const API = {
},
async fetchAssets() {
return await this.request('/api/assets');
return await this.request(`/api/assets?_t=${Date.now()}`);
},
async uploadFile(file) {
+144
View 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);
}
}
};
+98 -85
View File
@@ -5,12 +5,17 @@ import { API } from '../api.js';
import { EditorManager } from '../editor.js';
import { I18nManager } from '../utils/I18nManager.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 = {
DOM: {},
init(onSaveSuccess) {
// 타이밍 이슈 방지를 위해 DOM 요소 지연 할당
this.DOM = {
trigger: document.getElementById('composerTrigger'),
composer: document.getElementById('composer'),
@@ -21,11 +26,15 @@ export const ComposerManager = {
encryptionToggle: document.getElementById('encryptionToggle'),
password: document.getElementById('memoPassword'),
foldBtn: document.getElementById('foldBtn'),
discardBtn: document.getElementById('discardBtn')
discardBtn: document.getElementById('discardBtn'),
categoryBar: document.getElementById('composerCategoryBar')
};
if (!this.DOM.composer || !this.DOM.trigger) return;
this.selectedCategory = null;
this.isDoneStatus = false;
// 1. 이벤트 바인딩
this.DOM.trigger.onclick = () => this.openEmpty();
this.DOM.foldBtn.onclick = () => this.close();
@@ -44,28 +53,41 @@ export const ComposerManager = {
};
this.DOM.encryptionToggle.onclick = () => this.toggleEncryption();
this.initShortcutHint();
// 단축키 힌트 토글 바인딩
const shortcutToggle = document.getElementById('shortcutToggle');
const shortcutDetails = document.getElementById('shortcutDetails');
if (shortcutToggle && shortcutDetails) {
shortcutToggle.onclick = () => {
const isVisible = shortcutDetails.style.display !== 'none';
shortcutDetails.style.display = isVisible ? 'none' : 'flex';
const label = I18nManager.t('shortcuts_label');
shortcutToggle.textContent = isVisible ? label : `${label}`;
// 2. 자동 임시저장 및 키보드 리스너 등록
this.draftTimer = setInterval(() => this.saveDraft(), 3000);
ComposerDraft.checkRestore((draft) => this.restoreDraft(draft));
document.addEventListener('keydown', (e) => this.handleKeyDown(e));
},
initShortcutHint() {
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() {
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.trigger.style.display = 'none';
this.renderCategoryChips(); // 💡 초기화 후 칩 렌더링
this.DOM.title.focus();
},
@@ -77,6 +99,10 @@ export const ComposerManager = {
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.selectedCategory = memo.category || null;
this.isDoneStatus = memo.status === 'done';
EditorManager.setMarkdown(memo.content || '');
EditorManager.setAttachedFiles(memo.attachments || []);
@@ -86,6 +112,7 @@ export const ComposerManager = {
this.DOM.composer.style.display = 'block';
this.DOM.trigger.style.display = 'none';
this.renderCategoryChips(); // 💡 렌더링
window.scrollTo({ top: 0, behavior: 'smooth' });
},
@@ -94,6 +121,8 @@ export const ComposerManager = {
title: this.DOM.title.value.trim(),
content: EditorManager.getMarkdown(),
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),
is_encrypted: this.DOM.encryptionToggle.dataset.locked === 'true',
password: this.DOM.password.value.trim(),
@@ -106,7 +135,7 @@ export const ComposerManager = {
try {
await API.saveMemo(data, this.DOM.id.value);
EditorManager.sessionFiles.clear();
this.clearDraft();
ComposerDraft.clear(); // 💡 서브 모듈 위임
if (callback) await callback();
this.clear();
this.close();
@@ -123,9 +152,12 @@ export const ComposerManager = {
this.DOM.title.value = '';
this.DOM.group.value = Constants.GROUPS.DEFAULT;
this.DOM.tags.value = '';
this.selectedCategory = null;
this.isDoneStatus = false;
EditorManager.setMarkdown('');
EditorManager.setAttachedFiles([]);
this.setLocked(false);
this.renderCategoryChips();
},
toggleEncryption() {
@@ -137,89 +169,70 @@ export const ComposerManager = {
this.DOM.encryptionToggle.dataset.locked = locked;
this.DOM.encryptionToggle.innerText = locked ? '🔒' : '🔓';
this.DOM.password.style.display = locked ? 'block' : 'none';
// 비밀번호가 명시적으로 전달된 경우에만 업데이트 (해제 시 기존 비번 유지)
if (password !== null) {
this.DOM.password.value = password;
}
if (locked && !this.DOM.password.value) {
this.DOM.password.focus();
}
if (password !== null) this.DOM.password.value = password;
if (locked && !this.DOM.password.value) this.DOM.password.focus();
},
// === 자동 임시저장 (Auto-Draft) ===
// --- 서브 모듈 위임 메서드들 ---
/**
* 현재 에디터 내용을 localStorage에 자동 저장
*/
saveDraft() {
// 컴포저가 닫혀있으면 저장하지 않음
if (this.DOM.composer.style.display !== 'block') return;
const title = this.DOM.title.value;
const content = EditorManager.getMarkdown();
// 내용이 비어있으면 저장하지 않음
if (!title && !content) return;
const draft = {
title: title,
content: content,
group: this.DOM.group.value,
tags: this.DOM.tags.value,
editingId: this.DOM.id.value,
timestamp: Date.now()
};
localStorage.setItem('memo_draft', JSON.stringify(draft));
ComposerDraft.save(
this.DOM.id.value,
this.DOM.title.value,
this.DOM.group.value,
this.DOM.tags.value,
EditorManager.getMarkdown()
);
},
/**
* 페이지 로드 시 임시저장된 내용이 있으면 복원 확인
*/
checkDraftRestore() {
const raw = localStorage.getItem('memo_draft');
if (!raw) return;
try {
const draft = JSON.parse(raw);
// 24시간 이상 된 임시저장은 자동 삭제
if (Date.now() - draft.timestamp > 86400000) {
this.clearDraft();
return;
}
// 내용이 실제로 있는 경우에만 복원 확인
if (!draft.title && !draft.content) {
this.clearDraft();
return;
}
const titlePreview = draft.title || I18nManager.t('label_untitled');
const confirmMsg = I18nManager.t('msg_draft_restore_confirm')
.replace('{title}', titlePreview);
if (confirm(confirmMsg)) {
restoreDraft(draft) {
this.openEmpty();
this.DOM.title.value = draft.title || '';
this.DOM.group.value = draft.group || Constants.GROUPS.DEFAULT;
this.DOM.tags.value = draft.tags || '';
if (draft.editingId) this.DOM.id.value = draft.editingId;
EditorManager.setMarkdown(draft.content || '');
} else {
this.clearDraft();
}
} catch (e) {
console.warn('[Draft] Failed to parse draft, deleting:', e);
this.clearDraft();
}
},
/**
* 임시저장 데이터 삭제
*/
clearDraft() {
localStorage.removeItem('memo_draft');
renderCategoryChips() {
ComposerCategoryUI.render(
this.DOM.categoryBar,
this.selectedCategory,
this.isDoneStatus,
{
onSelect: (cat) => {
this.selectedCategory = (this.selectedCategory === cat) ? null : cat;
this.renderCategoryChips();
},
onToggleDone: () => {
this.isDoneStatus = !this.isDoneStatus;
this.renderCategoryChips();
}
}
);
},
handleKeyDown(e) {
if (this.DOM.composer.style.display !== 'block') return;
if (!e.altKey) return;
const key = e.key;
if (key === '1') {
e.preventDefault();
this.isDoneStatus = !this.isDoneStatus;
this.renderCategoryChips();
} else if (key === '2' || key === '3' || key === '4') {
e.preventDefault();
const cat = ComposerCategoryUI.getCategoryBySlot(parseInt(key) - 1);
if (cat) {
this.selectedCategory = (this.selectedCategory === cat) ? null : cat;
this.renderCategoryChips();
}
} else if (key === '5') {
e.preventDefault();
this.selectedCategory = null;
this.renderCategoryChips();
}
}
};
+6 -1
View File
@@ -36,8 +36,13 @@ export function createMemoCardHtml(memo, isDone) {
</div>
`;
} else {
// 본문에서 하단 메타데이터 블록(--- 이후)을 제외하고 렌더링 (중복 표시 방지)
let content = memo.content || '';
const footerIndex = content.lastIndexOf('\n\n---\n');
const displayContent = footerIndex !== -1 ? content.substring(0, footerIndex) : content;
// marked로 파싱한 후 DOMPurify로 살균하여 XSS 방지
htmlContent = DOMPurify.sanitize(marked.parse(memo.content || ''));
htmlContent = DOMPurify.sanitize(marked.parse(displayContent));
htmlContent = parseInternalLinks(htmlContent);
htmlContent = fixImagePaths(htmlContent);
}
+15 -2
View File
@@ -165,8 +165,21 @@ export const ModalManager = {
if (!memo) return;
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 = fixImagePaths(html);
+24
View File
@@ -45,3 +45,27 @@ export function renderGroupList(container, groups, activeGroup, onGroupClick) {
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);
}
}
+34 -4
View File
@@ -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');
window.addEventListener('click', (e) => {
@@ -57,13 +63,23 @@ export const ThemeManager = {
data[mapping[p.id]] = p.value;
});
data['enable_ai'] = document.getElementById('set-enable-ai').checked;
data['enable_categories'] = document.getElementById('set-enable-categories').checked;
// 언어 설정이 UI에 있다면 추가 (현재는 config.json 수동 명시 권장이나 대비책 마련)
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 {
await API.saveSettings(data);
// 언어가 변경되었다면 페이지를 새로고침하여 모든 매니저들을 새로운 언어로 재초기화합니다.
if (this.initialLang && this.initialLang !== newLang) {
alert(I18nManager.t('msg_settings_saved'));
window.location.reload();
return;
}
await this.applyTheme(data);
alert(I18nManager.t('msg_settings_saved'));
settingsModal.classList.remove('active');
@@ -80,7 +96,8 @@ export const ThemeManager = {
card_color: "rgba(30, 41, 59, 0.85)",
encrypted_border: "#00f3ff",
ai_accent: "#8b5cf6",
lang: "ko"
lang: "ko",
enable_categories: false
};
this.applyTheme(defaults);
}
@@ -92,6 +109,11 @@ export const ThemeManager = {
* 테마 데이터를 실제 CSS 변수 및 UI 요소에 반영
*/
async applyTheme(settings) {
this.settings = settings; // NEW: 설정 캐시 저장
if (window.UI) {
window.UI._updateSettingsCache(settings);
}
const mapping = {
'bg_color': '--bg',
'sidebar_color': '--sidebar',
@@ -117,7 +139,15 @@ export const ThemeManager = {
const aiToggle = document.getElementById('set-enable-ai');
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';
await I18nManager.init(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');
}
};
+7
View File
@@ -9,6 +9,13 @@ export const EditorManager = {
sessionFiles: new Set(), // 이번 세션에 새로 추가된 파일 트래킹 (취소 시 삭제용)
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;
// --- 플러그인 설정 (글자 색상) ---
+44 -1
View File
@@ -19,6 +19,9 @@ const DOM = {
scrollSentinel: document.getElementById('scrollSentinel')
};
// 모듈 레벨의 설정 캐시 관리 (this 바인딩 문제 해결)
let settingsCache = {};
export const UI = {
/**
* 사이드바 및 로그아웃 버튼 초기화
@@ -98,14 +101,51 @@ export const UI = {
/**
* 사이드바 시스템 고정 메뉴 상태 갱신
*/
updateSidebar(memos, activeGroup, onGroupClick) {
updateSidebar(memos, activeGroup, activeCategory, onGroupClick, onCategoryClick) {
if (!DOM.systemNav) return;
// 1. 시스템 그룹 동기화
DOM.systemNav.querySelectorAll('li').forEach(li => {
const group = li.dataset.group;
li.className = (group === activeGroup) ? 'active' : '';
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
View File
@@ -11,6 +11,8 @@
"nav_logout": "Logout",
"nav_settings": "Settings",
"nav_toggle": "Toggle Sidebar",
"nav_categories": "Categories",
"nav_category_manage": "Manage Categories",
"search_placeholder": "Search memos... (Title, Content, Tag)",
"composer_placeholder": "Leave a fragment of knowledge...",
@@ -31,6 +33,7 @@
"settings_security": "Security Border Color",
"settings_ai_accent": "AI Accent Color",
"settings_ai_enable": "Enable AI Features",
"settings_category_enable": "Enable Category Feature (Advanced)",
"settings_lang": "Language",
"settings_save": "Save Settings",
"settings_reset": "Reset",
@@ -90,6 +93,12 @@
"label_group_explorer": "📁 Group Explorer",
"label_tag_explorer": "🏷️ Tag Explorer",
"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",
"shortcut_save": "Save",
@@ -109,7 +118,9 @@
"h2": "Heading 2",
"h3": "Heading 3",
"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"],
+11
View File
@@ -11,6 +11,8 @@
"nav_logout": "로그아웃",
"nav_settings": "환경 설정",
"nav_toggle": "사이드바 토글",
"nav_categories": "카테고리",
"nav_category_manage": "카테고리 관리",
"search_placeholder": "메모 검색... (제목, 내용, 태그)",
"composer_placeholder": "지식의 파편을 남겨주세요...",
@@ -31,6 +33,7 @@
"settings_security": "보안 테두리색",
"settings_ai_accent": "AI 분석 강조색",
"settings_ai_enable": "AI 기능 활성화",
"settings_category_enable": "카테고리 기능 활성화 (고급)",
"settings_lang": "언어 설정",
"settings_save": "저장",
"settings_reset": "초기화",
@@ -89,6 +92,12 @@
"label_group_explorer": "📁 그룹별 탐색",
"label_tag_explorer": "🏷️ 태그별 탐색",
"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": "⌨️ 단축키",
"shortcut_save": "저장",
@@ -96,6 +105,8 @@
"shortcut_nebula": "네뷸라",
"shortcut_slash": "슬래시 명령",
"shortcut_edit": "즉시 수정",
"shortcut_cat_3": "Alt+4: %s",
"shortcut_cat_clear": "Alt+5: 분류 해제",
"slash": {
"task": "체크박스",
+51
View File
@@ -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>
+5
View File
@@ -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>
+60
View File
@@ -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>
+7
View File
@@ -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
View File
@@ -26,193 +26,20 @@
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
</head>
<body>
<aside class="sidebar" id="sidebar">
<div class="sidebar-header" style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 2.5rem;">
<h1 class="logo">🧠 <span class="text" data-i18n="app_name">Brain Dogfood</span></h1>
<button id="sidebarToggle" class="sidebar-toggle" data-i18n-title="nav_toggle"></button>
</div>
<div class="sidebar-content">
<ul class="nav" id="systemNav">
<li class="active" data-group="all" data-i18n-title="nav_all"><span class="icon">💡</span> <span class="text" data-i18n="nav_all">All Knowledge</span></li>
<li data-group="files" data-i18n-title="nav_files"><span class="icon">📂</span> <span class="text" data-i18n="nav_files">Files</span></li>
<li data-group="done" data-i18n-title="nav_done"><span class="icon"></span> <span class="text" data-i18n="nav_done">Done</span></li>
</ul>
<div class="sidebar-section">
<button id="openExplorerBtn" class="action-btn explorer-btn" style="width: 100%; justify-content: flex-start; margin-top: 15px; padding: 12px 15px; background: rgba(56, 189, 248, 0.1); border: 1px solid rgba(56, 189, 248, 0.2); color: var(--accent); font-weight: 600; border-radius: 12px;">
<span class="icon">🔍</span> <span class="text" data-i18n="nav_explorer">Knowledge Explorer</span>
</button>
</div>
<div class="sidebar-section">
<div id="calendarHeader" class="section-title" style="cursor: pointer; display: flex; align-items: center; justify-content: space-between; padding: 10px 15px; border-radius: 8px; margin-top: 10px; transition: background 0.2s;">
<span style="font-size: 0.9rem; font-weight: 600; color: var(--muted);"><span class="icon">📅</span> <span class="text" data-i18n="nav_calendar">Calendar</span></span>
<span id="calendarToggleIcon" style="font-size: 0.8rem; color: var(--muted);"></span>
</div>
<div id="calendarContainer" class="calendar-content">
<!-- JS will render calendar here -->
</div>
</div>
<div class="sidebar-section">
<div id="heatmapContainer">
<!-- JS will render heatmap here -->
</div>
</div>
<div class="sidebar-section">
<button id="openGraphBtn" class="action-btn" style="width: 100%; justify-content: flex-start; margin-top: 10px; padding: 10px 15px; background: rgba(139, 92, 246, 0.1); border: 1px solid rgba(139, 92, 246, 0.2); color: var(--ai-accent);">
<span class="icon">🕸️</span> <span class="text" data-i18n="nav_nebula">Knowledge Nebula</span>
</button>
</div>
</div>
<div class="sidebar-footer">
<button id="logoutBtn" class="action-btn" style="color: #ff4d4d;" data-i18n-tooltip="tooltip_logout">
<span class="icon">🚪</span> <span class="text" data-i18n="nav_logout">Logout</span>
</button>
<button id="settingsBtn" class="action-btn" data-i18n-tooltip="tooltip_settings">
<span class="icon">⚙️</span>
</button>
</div>
</aside>
{% include 'components/sidebar.html' %}
<main class="content">
<div class="topbar">
<button id="mobileMenuBtn" class="sidebar-toggle" style="display: none; margin-right: 15px;"></button>
<div class="search-bar">
<span class="search-icon">🔍</span>
<input type="text" id="searchInput" data-i18n-placeholder="search_placeholder">
</div>
</div>
<div class="composer-wrapper">
<!-- Accordion default closed state -->
<div id="composerTrigger" class="glass-panel" style="cursor: text;">
<span style="color: var(--muted); font-size: 1.1rem; font-weight: 600;" data-i18n="composer_placeholder_trigger">Capture knowledge or drop files...</span>
</div>
<!-- Actual Composer -->
<form id="composer" class="glass-panel" style="display: none;">
<input type="hidden" id="editingMemoId" value="">
<div style="display: flex; gap:10px; align-items:center; margin-bottom: 10px;">
<input type="text" id="memoTitle" data-i18n-placeholder="composer_title" autocomplete="off" style="flex: 1;">
<button type="button" id="foldBtn" class="action-btn" style="height:35px; width:35px; padding:0;" data-i18n-title="tooltip_fold"></button>
</div>
<div class="meta-inputs" style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center;">
<input type="text" id="memoGroup" data-i18n-placeholder="composer_group" class="meta-field" style="width: 120px;">
<input type="text" id="memoTags" data-i18n-placeholder="composer_tags" class="meta-field" style="flex: 1;">
<button type="button" id="encryptionToggle" class="action-btn" data-i18n-title="composer_encrypt" style="height:34px; padding:0 10px;">🔓</button>
<input type="password" id="memoPassword" data-i18n-placeholder="composer_password" class="meta-field" style="width: 120px; display: none;">
</div>
<div class="editor-resize-wrapper">
<div id="editor"></div>
</div>
<!-- Pending Attachments list in Composer -->
<div id="editorAttachments" class="memo-attachments" style="margin-top: 15px;"></div>
<!-- 키보드 단축키 힌트 (토글) -->
<div id="shortcutHint" class="shortcut-hint-bar">
<button type="button" id="shortcutToggle" class="shortcut-toggle-btn" data-i18n="shortcuts_label">⌨️ Shortcuts</button>
<div id="shortcutDetails" class="shortcut-details" style="display: none;">
<span class="sk"><kbd>Ctrl</kbd>+<kbd>Enter</kbd> <span data-i18n="shortcut_save">Save</span></span>
<span class="sk"><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd> / <kbd>Alt</kbd>+<kbd>`</kbd> <span data-i18n="shortcut_new">New Memo</span></span>
<span class="sk"><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd> <span data-i18n="shortcut_nebula">Nebula</span></span>
<span class="sk"><kbd>/</kbd> <span data-i18n="shortcut_slash">Slash Commands</span></span>
<span class="sk"><kbd>Alt</kbd>+<kbd>Click</kbd> <span data-i18n="shortcut_edit">Quick Edit</span></span>
</div>
</div>
<div class="composer-actions" style="display: flex; gap: 10px; margin-top: 15px; justify-content: flex-end;">
<button type="button" id="discardBtn" class="action-btn" style="background: rgba(255, 77, 77, 0.1); color: #ff4d4d; border-color: rgba(255, 77, 77, 0.2);" data-i18n="composer_discard">Discard (Delete)</button>
<button type="submit" id="submitBtn" class="primary-btn" data-i18n="composer_save">Save Memo</button>
</div>
</form>
</div>
<div class="masonry-grid" id="memoGrid">
<!-- Memos loaded here -->
</div>
<div id="scrollSentinel" style="height: 50px; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 0.9rem;">
</div>
{% include 'components/topbar.html' %}
{% include 'components/composer.html' %}
{% include 'components/memo_grid.html' %}
</main>
<!-- Modal for viewing memo details/links -->
<div id="memoModal" class="modal">
<div class="modal-content glass-panel" id="modalContent"></div>
</div>
<!-- Settings Modal -->
<div id="settingsModal" class="modal">
<div class="modal-content glass-panel" style="max-width: 400px; padding: 25px;">
<h2 style="margin-bottom: 20px; font-weight: 800; background: linear-gradient(135deg, #38bdf8, #8b5cf6); -webkit-background-clip: text; -webkit-text-fill-color: transparent;" data-i18n="settings_title">⚙️ Settings</h2>
<div class="settings-grid">
<label data-i18n="settings_bg">전체 배경색</label>
<input type="color" id="set-bg" data-var="--bg">
<label data-i18n="settings_sidebar">사이드바 색상</label>
<input type="color" id="set-sidebar" data-var="--sidebar">
<label data-i18n="settings_card">메모지 색상</label>
<input type="color" id="set-card" data-var="--card">
<label data-i18n="settings_security">보안 테두리색</label>
<input type="color" id="set-encrypted" data-var="--encrypted-border">
<label data-i18n="settings_ai_accent">AI 분석 강조색</label>
<input type="color" id="set-ai" data-var="--ai-accent">
<label style="font-weight: 800; color: var(--ai-accent);" data-i18n="settings_ai_enable">AI 기능 활성화</label>
<input type="checkbox" id="set-enable-ai" style="width: 20px; height: 20px; cursor: pointer;">
</div>
<div class="settings-grid">
<label data-i18n="settings_lang">언어 설정</label>
<select id="set-lang" class="meta-field" style="width: 100px;">
<option value="ko">한국어</option>
<option value="en">English</option>
</select>
</div>
<div class="settings-actions">
<button id="resetThemeBtn" class="action-btn" style="font-size: 0.85rem;" data-i18n="settings_reset">Reset</button>
<button id="saveThemeBtn" class="primary-btn" data-i18n="settings_save">Save</button>
<button id="closeSettingsBtn" class="action-btn" data-i18n="settings_close">Close</button>
</div>
</div>
</div>
<!-- AI Loading Overlay (Optional but nice) -->
<div id="loadingOverlay" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); backdrop-filter:blur(5px); z-index:2000; flex-direction:column; justify-content:center; align-items:center;">
<div class="spinner"></div>
<p style="margin-top:20px; font-weight:800; color:var(--accent);" data-i18n="msg_ai_loading">AI is analyzing the memo...</p>
</div>
<!-- Sidebar Overlay for mobile -->
<div id="sidebarOverlay" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.4); z-index:900; backdrop-filter:blur(2px);"></div>
<!-- Graph Modal -->
<div id="graphModal" class="modal">
<div class="modal-content glass-panel" style="width: 90%; height: 90%; max-width: none; overflow: hidden; position: relative; padding: 0; background: radial-gradient(circle at center, #1e293b 0%, #0f172a 100%);">
<div id="graphContainer" style="width: 100%; height: 100%; background-image: radial-gradient(circle, rgba(255,255,255,0.05) 1px, transparent 1px); background-size: 50px 50px;"></div>
<button id="closeGraphBtn" style="position: absolute; top: 20px; right: 20px; z-index: 100; background: rgba(0,0,0,0.5); border: none; color: white; border-radius: 50%; width: 40px; height: 40px; cursor: pointer; font-size: 20px;">×</button>
</div>
</div>
<!-- Knowledge Explorer Drawer -->
<div id="knowledgeDrawer" class="drawer">
<div class="drawer-header">
<h3 data-i18n="nav_explorer">🔍 Knowledge Explorer</h3>
<button id="closeDrawerBtn" class="close-btn">×</button>
</div>
<div id="drawerContent" class="drawer-body">
<!-- Groups/Tags will be injected here -->
</div>
</div>
{% include 'modals/memo_detail.html' %}
{% include 'modals/settings.html' %}
{% include 'modals/category.html' %}
{% include 'modals/graph.html' %}
{% include 'modals/explorer.html' %}
{% include 'modals/overlays.html' %}
<script type="module" src="/static/app.js?v=2.2"></script>
</body>
+19
View File
@@ -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;">&times;</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>
+10
View File
@@ -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>
+7
View File
@@ -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>
+4
View File
@@ -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>
+8
View File
@@ -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>
+43
View File
@@ -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>