mirror of
https://github.com/sotam0316/brain_dogfood.git
synced 2026-04-24 19:48:35 +09:00
Initial Global Release v1.0 (Localization & Security Hardening)
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# 뇌사료(Brain Dogfood) Environment Variables Template
|
||||
|
||||
# Flask Security
|
||||
SECRET_KEY=your_super_secret_key_here
|
||||
SESSION_LIFETIME_DAYS=30
|
||||
|
||||
# Authentication
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin
|
||||
|
||||
# AI Features (Optional)
|
||||
GEMINI_API_KEY=your_google_gemini_api_key_here
|
||||
GEMINI_MODEL=gemini-1.5-flash
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# Brain Dogfood Public Repo Gitignore
|
||||
.env
|
||||
config.json
|
||||
memos.db
|
||||
__pycache__/
|
||||
*.pyc
|
||||
logs/
|
||||
data/
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,108 @@
|
||||
[한국어](#한국어) | [English](#english)
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/img/main.png" alt="Brain Dogfood Dashboard" width="100%">
|
||||
<h1>🧠 뇌사료 (Brain Dogfood)</h1>
|
||||
<p><b>지식을 기록하는 습관을 넘어, 지능형 유기체로 성장하는 나만의 지식 창고</b></p>
|
||||
<p>Minimalist, AI-powered, Privacy-first Knowledge Server</p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **보안 주의사항 (Security Notice)**
|
||||
> - 기본 관리자 계정은 아이디: `admin` / 비밀번호: `.env` 파일에서 본인이 설정한 값입니다.
|
||||
> - 최초 로그인 후, 혹은 서버 실행 전 **`.env` 파일에서 `ADMIN_USERNAME`과 `ADMIN_PASSWORD`를 반드시 본인만의 정보로 수정**하세요. 수정하지 않을 경우 보안에 매우 취약해질 수 있습니다.
|
||||
|
||||
> [!NOTE]
|
||||
> **AI 기능은 선택 사항입니다 (AI is Optional)**
|
||||
> - **Gemini API 키가 없어도** 뇌사료의 핵심 기능(기본 메모, 히트맵, 지식 그래프 Nebula, 개별 암호화 등)은 **모두 정상 작동**합니다.
|
||||
> - AI 기능(`GEMINI_API_KEY`)은 자동 요약과 인공지능 태깅 기능을 사용할 때만 필요합니다.
|
||||
|
||||
---
|
||||
|
||||
<h2 id="한국어">📄 프로젝트 소개</h2>
|
||||
|
||||
**뇌사료(Brain Dogfood)**는 "내가 만든 지식은 내가 먼저 소비한다"는 철학에서 시작된 개인용 메모 서버입니다. 단순한 텍스트 기록을 넘어, AI가 당신의 지식을 분석하고 유기적인 그래프(Nebula)로 연결하여 새로운 통찰을 제공합니다.
|
||||
|
||||
### ✨ 독보적인 강점
|
||||
|
||||
* **Intelligent Nebula**: 단순히 태그로 묶는 것이 아닙니다. D3.js 기반의 그래프 시각화를 통해 지식 간의 관계를 시각적으로 탐험하세요.
|
||||
* **AI Insight Hub (Optional)**: Gemini 2.0 Flash가 모든 메모를 실시간으로 요약하고 최적의 태그를 제안합니다. 당신은 기록에만 집중하세요.
|
||||
* **Privacy-First Security**: 메모별로 개별 암호화를 지원합니다. 서버 관리자조차도 당신의 비밀번호 없이는 지식을 엿볼 수 없습니다.
|
||||
* **High-End UX**: 글래스모피즘 기반의 모던한 UI와 하이엔드 셰이더 효과, 그리고 빠른 생산성을 위한 풍부한 단축키 시스템을 제공합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🆚 memos vs 뇌사료 (Comparison)
|
||||
|
||||
| 기능 | **memos (Open Source)** | **🧠 뇌사료 (Brain Dogfood)** |
|
||||
| :--- | :--- | :--- |
|
||||
| **기본 철학** | 타임라인 기반 마이크로 블로깅 | 유기적인 지식 연결 및 AI 통찰 |
|
||||
| **시각화** | 단순 달력/히트맵 | **D3.js Knowledge Nebula (그래프)** |
|
||||
| **AI 통합** | 외부 플러그인 의존 | **Gemini 2.0 Native 통합 (자동 요약/태그 / 선택 사항)** |
|
||||
| **보안** | DB 전체 보안 | **메모별 개별 암호화 (Grain-level Security)** |
|
||||
| **사용성** | 모바일 앱 위주 | **데스크탑 생산성 최적화 (Slash Commands & Shortcuts)** |
|
||||
| **디자인** | 미니멀, 정적인 UI | **Modern Glassmorphism & 다이내믹 애니메이션** |
|
||||
|
||||
---
|
||||
|
||||
## ⌨️ 생산성 단축키
|
||||
|
||||
| 동작 | 단축키 | 설명 |
|
||||
| :--- | :--- | :--- |
|
||||
| **저장/수정** | `Ctrl + Enter` | 작성한 메모를 즉시 서버에 반영 |
|
||||
| **새 메모** | `Ctrl + Shift + N` | 언제 어디서든 즉시 작성창 호출 |
|
||||
| **슬래시 명령** | `/` | `/task`, `/ai`, `/h2` 등으로 빠른 서식 지정 |
|
||||
| **지식 탐색기** | `Ctrl + Shift + E` | 저장된 지식의 구조를 한눈에 파악 |
|
||||
| **즉시 수정** | `Alt + Click` | 메인 그리드에서 즉시 편집 모드 진입 |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 시작하기
|
||||
|
||||
```bash
|
||||
# 1. 저장소 복제 및 종속성 설치
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 2. .env.example을 .env로 복사 후 설정 수정 (필수)
|
||||
cp .env.example .env
|
||||
|
||||
# 3. 서버 실행
|
||||
python brain.py
|
||||
```
|
||||
|
||||
*`.env` 파일에서 관리자 아이디와 비밀번호를 꼭 수정하고, 필요한 경우에만 `GEMINI_API_KEY`를 등록하세요.*
|
||||
|
||||
---
|
||||
|
||||
<h2 id="english">🌐 English Description</h2>
|
||||
|
||||
### What is Brain Dogfood?
|
||||
**Brain Dogfood** is a minimalist yet powerful personal knowledge server built for those who value privacy and deep insights. It’s not just a memo app; it’s an **intelligent knowledge ecosystem** that grows with you.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Security Notice**:
|
||||
> Default credentials are set in the `.env` file. **You MUST change `ADMIN_USERNAME` and `ADMIN_PASSWORD`** in your `.env` file before running the server in a public environment.
|
||||
|
||||
> [!NOTE]
|
||||
> **AI is Optional**:
|
||||
> All core features (Memos, Heatmap, Knowledge Nebula, Encryption) work perfectly **without an AI API key**. The `GEMINI_API_KEY` is only required for automated summarization and AI tagging.
|
||||
|
||||
### Key Features
|
||||
- **AI-Driven Insights**: Powered by Gemini 2.0 Flash for instant summarization and smart tagging (Optional).
|
||||
- **Knowledge Nebula**: Explore your thoughts through a dynamic D3.js-based interactive knowledge graph.
|
||||
- **Advanced Security**: Grain-level encryption for individual memos – your data is for your eyes only.
|
||||
- **Premium Aesthetics**: Sleek glassmorphism UI with smooth micro-animations and production-ready UX.
|
||||
|
||||
### Quick Start
|
||||
1. Install dependencies: `pip install -r requirements.txt`
|
||||
2. Create your `.env` from `.env.example` and update your master credentials.
|
||||
3. Launch the server: `python brain.py` (Default port: 5050 on Windows, 5093 on Linux).
|
||||
|
||||
---
|
||||
<div align="center">
|
||||
<p>Developed with ❤️ for knowledge lovers.</p>
|
||||
</div>
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import os
|
||||
import json
|
||||
from flask import Flask, request, abort # type: ignore
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
def create_app():
|
||||
# Set folders to parent directory since app logic is now in a subfolder
|
||||
template_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'templates'))
|
||||
static_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'static'))
|
||||
|
||||
app = Flask(__name__, template_folder=template_dir, static_folder=static_dir)
|
||||
app.secret_key = os.getenv('SECRET_KEY', 'dev_key')
|
||||
|
||||
# --- 🛡️ 보안 실드 & 로깅 설정 ---
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
log_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'logs'))
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
file_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'app.log'),
|
||||
maxBytes=10*1024*1024, # 10MB
|
||||
backupCount=5,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler.setFormatter(logging.Formatter(
|
||||
'[%(asctime)s] %(levelname)s in %(module)s: %(message)s'
|
||||
))
|
||||
app.logger.addHandler(file_handler)
|
||||
app.logger.setLevel(logging.INFO)
|
||||
app.logger.info('🚀 뇌사료 서버 기동 - 로깅 시스템 가동')
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden(e):
|
||||
return "Forbidden: Suspicious activity detected. Your IP has been logged.", 403
|
||||
|
||||
@app.before_request
|
||||
def unified_logger():
|
||||
# 클라이언트 IP (Cloudflare 등을 거칠 경우 X-Forwarded-For 확인)
|
||||
ip = request.headers.get('X-Forwarded-For', request.remote_addr)
|
||||
path = request.path
|
||||
method = request.method
|
||||
params = request.query_string.decode('utf-8')
|
||||
|
||||
# 1. 보안 실드: 메인/로그인 페이지에 파라미터가 붙은 경우 즉시 차단
|
||||
if path.rstrip('/') in ['', '/login'] and params:
|
||||
log_msg = f"[SHIELD] Blocked: [{ip}] {method} {path}?{params}"
|
||||
app.logger.warning(log_msg)
|
||||
abort(403)
|
||||
|
||||
# 2. 트래픽 로깅 (정적 파일 제외)
|
||||
if not path.startswith('/static/'):
|
||||
log_msg = f"ACCESS: [{ip}] {method} {path}"
|
||||
if params:
|
||||
log_msg += f"?{params}"
|
||||
app.logger.info(log_msg)
|
||||
|
||||
upload_folder = os.path.abspath(os.path.join(static_dir, 'uploads'))
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
app.config['UPLOAD_FOLDER'] = upload_folder
|
||||
|
||||
# Load config.json
|
||||
config_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'config.json'))
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
app.config['UPLOAD_SECURITY'] = cfg.get('upload_security', {})
|
||||
else:
|
||||
app.config['UPLOAD_SECURITY'] = {'allowed_extensions': [], 'blocked_extensions': []}
|
||||
|
||||
# Initialize DB schema
|
||||
from .database import init_db
|
||||
init_db()
|
||||
|
||||
# Session and Security configurations
|
||||
app.config.update(
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE='Lax',
|
||||
SESSION_COOKIE_SECURE=False, # Set to True in production with HTTPS
|
||||
PERMANENT_SESSION_LIFETIME=3600 # 60 minutes (1 hour) session
|
||||
)
|
||||
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
"""보안 강화를 위한 HTTP 헤더 추가"""
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
# Content Security Policy (Toast UI 및 외부 CDN 허용)
|
||||
# 운영 환경에 맞춰 점진적으로 강화 가능
|
||||
csp = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://uicdn.toast.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://static.cloudflareinsights.com https://d3js.org; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://uicdn.toast.com; "
|
||||
"font-src 'self' https://fonts.gstatic.com; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://cloudflareinsights.com https://d3js.org;"
|
||||
)
|
||||
response.headers['Content-Security-Policy'] = csp
|
||||
return response
|
||||
|
||||
# Register modular blueprints
|
||||
from .routes import register_blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,95 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from google import genai
|
||||
from .utils.i18n import _t
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger('ai')
|
||||
|
||||
def analyze_memo(title, content, lang='en'):
|
||||
"""
|
||||
최신 google-genai SDK를 사용하여 메모 본문을 요약하고 태그를 추출합니다.
|
||||
"""
|
||||
api_key = os.getenv('GEMINI_API_KEY')
|
||||
primary_model = os.getenv('GEMINI_MODEL', 'gemini-2.0-flash')
|
||||
|
||||
# 예비용 모델 리스트
|
||||
fallbacks = [primary_model, 'gemini-1.5-flash-latest', 'gemini-1.5-flash']
|
||||
models_to_try = list(dict.fromkeys(fallbacks))
|
||||
|
||||
if not api_key:
|
||||
error_msg = "Gemini API key is required." if lang == 'en' else "AI 분석을 위해 Gemini API 키가 필요합니다."
|
||||
return error_msg, []
|
||||
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
if lang == 'ko':
|
||||
prompt = f"""
|
||||
당신은 메모 분석 전문가입니다. 아래 메모의 제목과 내용을 읽고 다음 작업을 수행하세요:
|
||||
1. 내용을 1~2문장으로 아주 간결하게 요약할 것.
|
||||
2. 내용과 관련된 핵심 키워드를 태그 형태로 3~5개 추출할 것.
|
||||
|
||||
[제목] {title}
|
||||
[내용] {content}
|
||||
|
||||
출력 형식(JSON):
|
||||
{{
|
||||
"summary": "요약 내용",
|
||||
"tags": ["태그1", "태그2", "태그3"]
|
||||
}}
|
||||
"""
|
||||
else:
|
||||
prompt = f"""
|
||||
You are a memo analysis expert. Read the title and content below and perform the following:
|
||||
1. Summarize the content very concisely in 1-2 sentences.
|
||||
2. Extract 3-5 key keywords as tags.
|
||||
|
||||
[Title] {title}
|
||||
[Content] {content}
|
||||
|
||||
Output Format (JSON):
|
||||
{{
|
||||
"summary": "Summary text",
|
||||
"tags": ["Tag1", "Tag2", "Tag3"]
|
||||
}}
|
||||
"""
|
||||
|
||||
last_error = None
|
||||
for model_name in models_to_try:
|
||||
try:
|
||||
logger.info(f"[AI] Attempting analysis with model: {model_name} (lang={lang})")
|
||||
response = client.models.generate_content(
|
||||
model=model_name,
|
||||
contents=prompt
|
||||
)
|
||||
|
||||
res_text = response.text.strip()
|
||||
if '```json' in res_text:
|
||||
res_text = res_text.split('```json')[1].split('```')[0].strip()
|
||||
elif '```' in res_text:
|
||||
res_text = res_text.split('```')[1].strip()
|
||||
|
||||
data = json.loads(res_text)
|
||||
logger.info(f"[AI] Analysis successful using {model_name}")
|
||||
return data.get('summary', ''), data.get('tags', [])
|
||||
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.warning(f"[AI] Model {model_name} failed: {str(e)}")
|
||||
if "404" in str(e):
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
||||
# 모든 시도가 실패한 경우
|
||||
error_header = "AI Analysis Error: " if lang == 'en' else "AI 분석 중 오류 발생: "
|
||||
error_msg = f"{error_header}{str(last_error)}"
|
||||
|
||||
if "404" in str(last_error):
|
||||
if lang == 'en':
|
||||
error_msg += "\n(Note: The selected model might be expired. Check GEMINI_MODEL in .env)"
|
||||
else:
|
||||
error_msg += "\n(알림: 선택한 AI 모델이 만료되었을 수 있습니다. .env의 GEMINI_MODEL 설정을 확인해주세요.)"
|
||||
|
||||
return error_msg, []
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
import functools
|
||||
from flask import session, redirect, url_for, request, current_app # type: ignore
|
||||
|
||||
def check_auth(username, password):
|
||||
"""
|
||||
환경 변수에 설정된 관리자 계정 정보와 일치하는지 확인합니다.
|
||||
"""
|
||||
admin_user = os.getenv('ADMIN_USERNAME', 'admin')
|
||||
admin_password = os.getenv('ADMIN_PASSWORD', 'admin')
|
||||
return username == admin_user and password == admin_password
|
||||
|
||||
def login_required(view):
|
||||
@functools.wraps(view)
|
||||
def wrapped_view(**kwargs):
|
||||
# app/routes/auth.py의 세션 키와 일치시킴 (logged_in)
|
||||
if session.get('logged_in') is None:
|
||||
return redirect(url_for('main.login_page'))
|
||||
return view(**kwargs)
|
||||
return wrapped_view
|
||||
@@ -0,0 +1,8 @@
|
||||
# System-wide Core Constants (Globalization-ready)
|
||||
|
||||
# System Reserved Groups
|
||||
GROUP_DEFAULT = "default" # 기본
|
||||
GROUP_FILES = "files" # 파일모음
|
||||
GROUP_DONE = "done" # 완료모음
|
||||
|
||||
SYSTEM_GROUPS = [GROUP_DEFAULT, GROUP_FILES, GROUP_DONE]
|
||||
@@ -0,0 +1,83 @@
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
# Data directory relative to this file
|
||||
DB_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'data'))
|
||||
os.makedirs(DB_DIR, exist_ok=True)
|
||||
DB_PATH = os.path.join(DB_DIR, 'memos.db')
|
||||
|
||||
def init_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
|
||||
# 1. Memos Table
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS memos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
summary TEXT,
|
||||
color TEXT DEFAULT '#2c3e50',
|
||||
is_pinned BOOLEAN DEFAULT 0,
|
||||
status TEXT DEFAULT 'active', -- 'active', 'done', 'archived'
|
||||
group_name TEXT DEFAULT 'default',
|
||||
is_encrypted BOOLEAN DEFAULT 0,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
try:
|
||||
c.execute("ALTER TABLE memos ADD COLUMN status TEXT DEFAULT 'active'")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
try:
|
||||
c.execute("ALTER TABLE memos ADD COLUMN is_encrypted BOOLEAN DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
|
||||
# 2. Separate Tags Table (Normalized)
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
memo_id INTEGER,
|
||||
name TEXT,
|
||||
source TEXT, -- 'user' or 'ai'
|
||||
FOREIGN KEY (memo_id) REFERENCES memos (id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
# 3. Attachments Table (Enhanced Asset Tracking)
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
memo_id INTEGER,
|
||||
filename TEXT,
|
||||
original_name TEXT,
|
||||
file_type TEXT,
|
||||
size INTEGER,
|
||||
created_at TIMESTAMP,
|
||||
FOREIGN KEY (memo_id) REFERENCES memos (id) ON DELETE SET NULL
|
||||
)
|
||||
''')
|
||||
|
||||
# 4. Memo Links Table (Backlinks)
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS memo_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_id INTEGER,
|
||||
target_id INTEGER,
|
||||
FOREIGN KEY (source_id) REFERENCES memos (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_id) REFERENCES memos (id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
@@ -0,0 +1,16 @@
|
||||
from flask import Blueprint # type: ignore
|
||||
|
||||
def register_blueprints(app):
|
||||
from .main import main_bp
|
||||
from .auth import auth_bp
|
||||
from .memo import memo_bp
|
||||
from .file import file_bp
|
||||
from .ai import ai_bp
|
||||
from .settings import settings_bp
|
||||
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(memo_bp)
|
||||
app.register_blueprint(file_bp)
|
||||
app.register_blueprint(ai_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
@@ -0,0 +1,46 @@
|
||||
import datetime
|
||||
from flask import Blueprint, jsonify, current_app # type: ignore
|
||||
from ..database import get_db
|
||||
from ..auth import login_required
|
||||
from ..ai import analyze_memo
|
||||
from ..utils.i18n import _t
|
||||
|
||||
ai_bp = Blueprint('ai', __name__)
|
||||
|
||||
@ai_bp.route('/api/memos/<int:memo_id>/analyze', methods=['POST'])
|
||||
@login_required
|
||||
def analyze_memo_route(memo_id):
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT title, content, is_encrypted FROM memos WHERE id = ?', (memo_id,))
|
||||
memo = c.fetchone()
|
||||
|
||||
if not memo:
|
||||
return jsonify({'error': _t('label_no_results')}), 404
|
||||
|
||||
if memo['is_encrypted']:
|
||||
return jsonify({'error': _t('msg_encrypted_locked')}), 403
|
||||
|
||||
current_app.logger.info(f"AI Analysis Started: ID {memo_id}, Title: '{memo['title']}'")
|
||||
|
||||
lang = current_app.config.get('lang', 'en')
|
||||
summary, ai_tags = analyze_memo(memo['title'], memo['content'], lang=lang)
|
||||
|
||||
try:
|
||||
c.execute('UPDATE memos SET summary = ?, updated_at = ? WHERE id = ?',
|
||||
(summary, datetime.datetime.now().isoformat(), memo_id))
|
||||
|
||||
c.execute("DELETE FROM tags WHERE memo_id = ? AND source = 'ai'", (memo_id,))
|
||||
for tag in ai_tags:
|
||||
if tag.strip():
|
||||
c.execute('INSERT INTO tags (memo_id, name, source) VALUES (?, ?, ?)',
|
||||
(memo_id, tag.strip(), 'ai'))
|
||||
|
||||
conn.commit()
|
||||
current_app.logger.info(f"AI Analysis SUCCESS: ID {memo_id}, Tags extracted: {len(ai_tags)}")
|
||||
return jsonify({'summary': summary, 'tags': ai_tags})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,22 @@
|
||||
from flask import Blueprint, request, jsonify, session, redirect, url_for # type: ignore
|
||||
from ..auth import check_auth
|
||||
from ..utils.i18n import _t
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
@auth_bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
data = request.json
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
|
||||
if check_auth(username, password):
|
||||
session.permanent = True # Enable permanent session to use LIFETIME config
|
||||
session['logged_in'] = True
|
||||
return jsonify({'message': 'Logged in successfully'})
|
||||
return jsonify({'error': _t('msg_auth_failed')}), 401
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
def logout():
|
||||
session.pop('logged_in', None)
|
||||
return redirect(url_for('main.login_page'))
|
||||
@@ -0,0 +1,162 @@
|
||||
import os
|
||||
import uuid
|
||||
import datetime
|
||||
import mimetypes
|
||||
from flask import Blueprint, request, jsonify, current_app, Response, send_from_directory, session # type: ignore
|
||||
from werkzeug.utils import secure_filename # type: ignore
|
||||
from urllib.parse import quote # type: ignore
|
||||
from ..database import get_db
|
||||
from ..auth import login_required
|
||||
from ..security import encrypt_file, decrypt_file
|
||||
|
||||
file_bp = Blueprint('file', __name__)
|
||||
|
||||
@file_bp.route('/api/upload', methods=['POST'])
|
||||
@login_required
|
||||
def upload_file():
|
||||
if 'image' not in request.files and 'file' not in request.files:
|
||||
return jsonify({'error': 'No file provided'}), 400
|
||||
|
||||
file = request.files.get('image') or request.files.get('file')
|
||||
if not file or file.filename == '':
|
||||
return jsonify({'error': 'No selected file'}), 400
|
||||
|
||||
ext = os.path.splitext(file.filename)[1].lower().replace('.', '')
|
||||
sec_conf = current_app.config.get('UPLOAD_SECURITY', {})
|
||||
blocked = sec_conf.get('blocked_extensions', [])
|
||||
allowed = sec_conf.get('allowed_extensions', [])
|
||||
|
||||
if ext in blocked:
|
||||
return jsonify({'error': f'Extension .{ext} is blocked for security reasons.'}), 403
|
||||
if allowed and ext not in allowed:
|
||||
return jsonify({'error': f'Extension .{ext} is not in the allowed list.'}), 403
|
||||
|
||||
unique_filename = f"{uuid.uuid4()}.{ext}"
|
||||
filename = secure_filename(unique_filename)
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
|
||||
# Encrypt and save
|
||||
file_bytes = file.read()
|
||||
encrypted_bytes = encrypt_file(file_bytes)
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(encrypted_bytes)
|
||||
|
||||
# Record attachment in DB
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
INSERT INTO attachments (filename, original_name, file_type, size, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (filename, file.filename, ext, os.path.getsize(filepath), datetime.datetime.now().isoformat()))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'url': f"/api/download/{filename}",
|
||||
'name': file.filename,
|
||||
'ext': ext
|
||||
})
|
||||
|
||||
@file_bp.route('/api/download/<filename>')
|
||||
def download_file_route(filename):
|
||||
filename = secure_filename(filename)
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
|
||||
# Check security status of parent memo
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
SELECT a.original_name, m.is_encrypted
|
||||
FROM attachments a
|
||||
LEFT JOIN memos m ON a.memo_id = m.id
|
||||
WHERE a.filename = ?
|
||||
''', (filename,))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
|
||||
# 로그인된 상태라면 암호화된 메모의 파일이라도 본인 확인이 된 것으로 간주하고 허용
|
||||
is_logged_in = session.get('logged_in') is True
|
||||
|
||||
# 만약 메모가 암호화되어 있고, 로그인도 되어 있지 않다면 차단
|
||||
if row and row['is_encrypted'] and not is_logged_in:
|
||||
current_app.logger.warning(f"Access Denied: Unauthenticated access to encrypted file {filename}")
|
||||
return jsonify({'error': 'Access denied. Please login to view this attachment.'}), 403
|
||||
|
||||
with open(filepath, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
decrypted = decrypt_file(data)
|
||||
|
||||
orig_name = row['original_name'] if row else filename
|
||||
# 원본 파일명 기반으로 정확한 마임타입 추측
|
||||
mime_type, _ = mimetypes.guess_type(orig_name)
|
||||
if not mime_type: mime_type = 'application/octet-stream'
|
||||
|
||||
# 이미지인 경우 'inline'으로 설정하여 브라우저 본문 내 렌더링 허용, 그 외는 'attachment'
|
||||
is_image = mime_type.startswith('image/')
|
||||
disposition = 'inline' if is_image else 'attachment'
|
||||
|
||||
headers = {
|
||||
'Content-Disposition': f"{disposition}; filename*=UTF-8''{quote(orig_name)}"
|
||||
}
|
||||
|
||||
content_data = decrypted if decrypted is not None else data
|
||||
return Response(content_data, mimetype=mime_type, headers=headers)
|
||||
|
||||
@file_bp.route('/api/assets', methods=['GET'])
|
||||
@login_required
|
||||
def get_assets():
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
# Filter out files belonging to encrypted memos
|
||||
c.execute('''
|
||||
SELECT a.*, m.title as memo_title
|
||||
FROM attachments a
|
||||
LEFT JOIN memos m ON a.memo_id = m.id
|
||||
WHERE m.is_encrypted = 0 OR m.is_encrypted IS NULL
|
||||
ORDER BY a.created_at DESC
|
||||
''')
|
||||
assets = [dict(r) for r in c.fetchall()]
|
||||
conn.close()
|
||||
return jsonify(assets)
|
||||
@file_bp.route('/api/attachments/<filename>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_attachment_route(filename):
|
||||
filename = secure_filename(filename)
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
# 파일 정보 확인
|
||||
c.execute('SELECT id, memo_id FROM attachments WHERE filename = ?', (filename,))
|
||||
row = c.fetchone()
|
||||
|
||||
if not row:
|
||||
conn.close()
|
||||
return jsonify({'error': 'File not found in database'}), 404
|
||||
|
||||
# 보안: 메모에 이미 연결된 파일은 삭제하지 않음 (취소 시에는 아직 연결되지 않은 파일만 삭제)
|
||||
# 만약 연결된 파일을 삭제하고 싶다면 별도의 로직 필요
|
||||
if row['memo_id'] is not None:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Cannot delete file already linked to a memo'}), 403
|
||||
|
||||
try:
|
||||
# 1. DB 삭제
|
||||
c.execute('DELETE FROM attachments WHERE filename = ?', (filename,))
|
||||
conn.commit()
|
||||
|
||||
# 2. 물리 파일 삭제
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
current_app.logger.info(f"Attachment Deleted: {filename}")
|
||||
return jsonify({'message': 'File deleted successfully'})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,27 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, session, current_app # type: ignore
|
||||
from ..auth import login_required
|
||||
import os
|
||||
import json
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
@main_bp.route('/login', methods=['GET'])
|
||||
def login_page():
|
||||
if 'logged_in' in session:
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
# i18n 지원을 위해 기본 언어 전달
|
||||
config_path = os.path.join(os.getcwd(), 'config.json')
|
||||
lang = 'ko'
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
lang = json.load(f).get('lang', 'ko')
|
||||
except: pass
|
||||
|
||||
return render_template('login.html', lang=lang)
|
||||
@@ -0,0 +1,356 @@
|
||||
import datetime
|
||||
from flask import Blueprint, request, jsonify, current_app # type: ignore
|
||||
from ..database import get_db
|
||||
from ..auth import login_required
|
||||
from ..constants import GROUP_DONE, GROUP_DEFAULT
|
||||
from ..utils.i18n import _t
|
||||
from ..utils import extract_links
|
||||
from ..security import encrypt_content, decrypt_content
|
||||
|
||||
memo_bp = Blueprint('memo', __name__)
|
||||
|
||||
@memo_bp.route('/api/memos', methods=['GET'])
|
||||
@login_required
|
||||
def get_memos():
|
||||
limit = request.args.get('limit', 20, type=int)
|
||||
offset = request.args.get('offset', 0, type=int)
|
||||
group = request.args.get('group', 'all')
|
||||
query = request.args.get('query', '')
|
||||
date = request.args.get('date', '')
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
# 1. 그룹/태그 필터링
|
||||
if group == GROUP_DONE:
|
||||
where_clauses.append("status = 'done'")
|
||||
elif group.startswith('tag:'):
|
||||
tag_name = group.split(':')[-1]
|
||||
where_clauses.append("status != 'done'")
|
||||
where_clauses.append("id IN (SELECT memo_id FROM tags WHERE name = ?)")
|
||||
params.append(tag_name)
|
||||
elif group != 'all':
|
||||
where_clauses.append("status != 'done'")
|
||||
where_clauses.append("group_name = ?")
|
||||
params.append(group)
|
||||
else:
|
||||
where_clauses.append("status != 'done'")
|
||||
|
||||
# 2. 검색어 필터링
|
||||
if query:
|
||||
where_clauses.append("(title LIKE ? OR content LIKE ?)")
|
||||
params.append(f"%{query}%")
|
||||
params.append(f"%{query}%")
|
||||
|
||||
# 3. 날짜 필터링 (캘린더 선택)
|
||||
if date:
|
||||
where_clauses.append("created_at LIKE ?")
|
||||
params.append(f"{date}%")
|
||||
|
||||
# 4. 초기 로드 시 최근 5일 강조 (필터가 없는 경우에만 적용)
|
||||
if offset == 0 and group == 'all' and not query and not date:
|
||||
start_date = (datetime.datetime.now() - datetime.timedelta(days=5)).isoformat()
|
||||
where_clauses.append("(updated_at >= ? OR is_pinned = 1)")
|
||||
params.append(start_date)
|
||||
|
||||
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
||||
|
||||
query_sql = f"SELECT * FROM memos WHERE {where_sql} ORDER BY is_pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
|
||||
c.execute(query_sql, params + [limit, offset])
|
||||
memo_rows = c.fetchall()
|
||||
|
||||
if not memo_rows:
|
||||
conn.close()
|
||||
return jsonify([])
|
||||
|
||||
memos = [dict(r) for r in memo_rows]
|
||||
memo_ids = [m['id'] for m in memos]
|
||||
placeholders = ','.join(['?'] * len(memo_ids))
|
||||
|
||||
# --- 🚀 Bulk Fetch: N+1 문제 해결 ---
|
||||
|
||||
# 태그 한꺼번에 가져오기
|
||||
c.execute(f'SELECT memo_id, name, source FROM tags WHERE memo_id IN ({placeholders})', memo_ids)
|
||||
tags_rows = c.fetchall()
|
||||
tags_map = {}
|
||||
for t in tags_rows:
|
||||
tags_map.setdefault(t['memo_id'], []).append(dict(t))
|
||||
|
||||
# 첨부파일 한꺼번에 가져오기
|
||||
c.execute(f'SELECT id, memo_id, filename, original_name, file_type, size FROM attachments WHERE memo_id IN ({placeholders})', memo_ids)
|
||||
attachments_rows = c.fetchall()
|
||||
attachments_map = {}
|
||||
for a in attachments_rows:
|
||||
attachments_map.setdefault(a['memo_id'], []).append(dict(a))
|
||||
|
||||
# 백링크 한꺼번에 가져오기
|
||||
c.execute(f'''
|
||||
SELECT ml.target_id, m.id as source_id, m.title
|
||||
FROM memo_links ml
|
||||
JOIN memos m ON ml.source_id = m.id
|
||||
WHERE ml.target_id IN ({placeholders})
|
||||
''', memo_ids)
|
||||
backlinks_rows = c.fetchall()
|
||||
backlinks_map = {}
|
||||
for l in backlinks_rows:
|
||||
backlinks_map.setdefault(l['target_id'], []).append(dict(l))
|
||||
|
||||
# 전방 링크(Forward Links) 한꺼번에 가져오기
|
||||
c.execute(f'''
|
||||
SELECT ml.source_id, m.id as target_id, m.title
|
||||
FROM memo_links ml
|
||||
JOIN memos m ON ml.target_id = m.id
|
||||
WHERE ml.source_id IN ({placeholders})
|
||||
''', memo_ids)
|
||||
links_rows = c.fetchall()
|
||||
links_map = {}
|
||||
for l in links_rows:
|
||||
links_map.setdefault(l['source_id'], []).append(dict(l))
|
||||
|
||||
# 데이터 가공 및 병합
|
||||
for m in memos:
|
||||
m['tags'] = tags_map.get(m['id'], [])
|
||||
m['attachments'] = attachments_map.get(m['id'], [])
|
||||
m['backlinks'] = backlinks_map.get(m['id'], [])
|
||||
m['links'] = links_map.get(m['id'], [])
|
||||
|
||||
conn.close()
|
||||
return jsonify(memos)
|
||||
|
||||
@memo_bp.route('/api/stats/heatmap', methods=['GET'])
|
||||
@login_required
|
||||
def get_heatmap_stats():
|
||||
days = request.args.get('days', 365, type=int)
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
# 파라미터로 받은 일수만큼 데이터 조회
|
||||
start_date = (datetime.datetime.now() - datetime.timedelta(days=days)).isoformat()
|
||||
|
||||
c.execute('''
|
||||
SELECT strftime('%Y-%m-%d', created_at) as date, COUNT(*) as count
|
||||
FROM memos
|
||||
WHERE created_at >= ?
|
||||
GROUP BY date
|
||||
''', (start_date,))
|
||||
|
||||
stats = c.fetchall()
|
||||
conn.close()
|
||||
return jsonify([dict(s) for s in stats])
|
||||
|
||||
@memo_bp.route('/api/memos', methods=['POST'])
|
||||
@login_required
|
||||
def create_memo():
|
||||
data = request.json
|
||||
title = data.get('title', '').strip()
|
||||
content = data.get('content', '').strip()
|
||||
color = data.get('color', '#2c3e50')
|
||||
is_pinned = 1 if data.get('is_pinned') else 0
|
||||
status = data.get('status', 'active').strip()
|
||||
group_name = data.get('group_name', GROUP_DEFAULT).strip()
|
||||
user_tags = data.get('tags', [])
|
||||
is_encrypted = 1 if data.get('is_encrypted') else 0
|
||||
password = data.get('password', '').strip()
|
||||
|
||||
if is_encrypted and password:
|
||||
content = encrypt_content(content, password)
|
||||
elif is_encrypted and not password:
|
||||
return jsonify({'error': 'Password required for encryption'}), 400
|
||||
|
||||
now = datetime.datetime.now().isoformat()
|
||||
if not title and not content:
|
||||
return jsonify({'error': 'Title or content required'}), 400
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute('''
|
||||
INSERT INTO memos (title, content, color, is_pinned, status, group_name, is_encrypted, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (title, content, color, is_pinned, status, group_name, is_encrypted, now, now))
|
||||
memo_id = c.lastrowid
|
||||
|
||||
for tag in user_tags:
|
||||
if tag.strip():
|
||||
c.execute('INSERT INTO tags (memo_id, name, source) VALUES (?, ?, ?)', (memo_id, tag.strip(), 'user'))
|
||||
|
||||
links = extract_links(content)
|
||||
for target_id in links:
|
||||
c.execute('INSERT INTO memo_links (source_id, target_id) VALUES (?, ?)', (memo_id, target_id))
|
||||
|
||||
attachment_filenames = data.get('attachment_filenames', [])
|
||||
for fname in set(attachment_filenames):
|
||||
c.execute('UPDATE attachments SET memo_id = ? WHERE filename = ?', (memo_id, fname))
|
||||
|
||||
conn.commit()
|
||||
current_app.logger.info(f"Memo Created: ID {memo_id}, Title: '{title}', Encrypted: {is_encrypted}")
|
||||
return jsonify({'id': memo_id, 'message': 'Memo created'}), 201
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@memo_bp.route('/api/memos/<int:memo_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_memo(memo_id):
|
||||
data = request.json
|
||||
title = data.get('title')
|
||||
content = data.get('content')
|
||||
color = data.get('color')
|
||||
is_pinned = data.get('is_pinned')
|
||||
status = data.get('status')
|
||||
group_name = data.get('group_name')
|
||||
user_tags = data.get('tags')
|
||||
is_encrypted = data.get('is_encrypted')
|
||||
password = data.get('password', '').strip()
|
||||
|
||||
now = datetime.datetime.now().isoformat()
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
# 보안: 암호화된 메모 수정 시 비밀번호 검증
|
||||
c.execute('SELECT content, is_encrypted FROM memos WHERE id = ?', (memo_id,))
|
||||
memo = c.fetchone()
|
||||
if memo and memo['is_encrypted']:
|
||||
# 암호화된 메모지만 '암호화 해제(is_encrypted=0)' 요청이 온 경우는
|
||||
# 비밀번호 없이도 수정을 시도할 수 있어야 할까? (아니오, 인증이 필요함)
|
||||
if not password:
|
||||
conn.close()
|
||||
return jsonify({'error': _t('msg_encrypted_locked')}), 403
|
||||
|
||||
# 비밀번호가 맞는지 검증 (복호화 시도)
|
||||
if decrypt_content(memo['content'], password) is None:
|
||||
conn.close()
|
||||
return jsonify({'error': _t('msg_auth_failed')}), 403
|
||||
|
||||
try:
|
||||
updates = ['updated_at = ?']
|
||||
params = [now]
|
||||
|
||||
# 암호화 처리 로직: 암호화가 활성화된 경우(또는 새로 설정하는 경우) 본문 암호화
|
||||
final_content = content.strip() if content is not None else None
|
||||
if (is_encrypted or (is_encrypted is None and memo['is_encrypted'])) and password:
|
||||
if final_content is not None:
|
||||
final_content = encrypt_content(final_content, password)
|
||||
|
||||
if title is not None:
|
||||
updates.append('title = ?'); params.append(title.strip())
|
||||
if final_content is not None:
|
||||
updates.append('content = ?'); params.append(final_content)
|
||||
if color is not None:
|
||||
updates.append('color = ?'); params.append(color)
|
||||
if is_pinned is not None:
|
||||
updates.append('is_pinned = ?'); params.append(1 if is_pinned else 0)
|
||||
if status is not None:
|
||||
updates.append('status = ?'); params.append(status.strip())
|
||||
if group_name is not None:
|
||||
updates.append('group_name = ?'); params.append(group_name.strip() or GROUP_DEFAULT)
|
||||
if is_encrypted is not None:
|
||||
updates.append('is_encrypted = ?'); params.append(1 if is_encrypted else 0)
|
||||
|
||||
params.append(memo_id)
|
||||
c.execute(f"UPDATE memos SET {', '.join(updates)} WHERE id = ?", params)
|
||||
|
||||
if user_tags is not None:
|
||||
c.execute("DELETE FROM tags WHERE memo_id = ? AND source = 'user'", (memo_id,))
|
||||
for tag in user_tags:
|
||||
if tag.strip():
|
||||
c.execute('INSERT INTO tags (memo_id, name, source) VALUES (?, ?, ?)', (memo_id, tag.strip(), 'user'))
|
||||
|
||||
if content is not None:
|
||||
c.execute("DELETE FROM memo_links WHERE source_id = ?", (memo_id,))
|
||||
links = extract_links(content)
|
||||
for target_id in links:
|
||||
c.execute('INSERT INTO memo_links (source_id, target_id) VALUES (?, ?)', (memo_id, target_id))
|
||||
|
||||
# [Bug Fix] 첨부파일 링크는 본문 수정 여부와 상관없이 항상 갱신
|
||||
attachment_filenames = data.get('attachment_filenames')
|
||||
if attachment_filenames is not None:
|
||||
c.execute('UPDATE attachments SET memo_id = NULL WHERE memo_id = ?', (memo_id,))
|
||||
for fname in set(attachment_filenames):
|
||||
c.execute('UPDATE attachments SET memo_id = ? WHERE filename = ?', (memo_id, fname))
|
||||
|
||||
if is_encrypted is not None:
|
||||
if is_encrypted and password and content:
|
||||
enc_content = encrypt_content(content, password)
|
||||
c.execute("UPDATE memos SET is_encrypted = 1, content = ? WHERE id = ?", (enc_content, memo_id))
|
||||
elif is_encrypted == 0:
|
||||
c.execute("UPDATE memos SET is_encrypted = 0 WHERE id = ?", (memo_id,))
|
||||
|
||||
conn.commit()
|
||||
current_app.logger.info(f"Memo Updated: ID {memo_id}, Fields: {list(data.keys())}")
|
||||
return jsonify({'message': 'Updated'})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@memo_bp.route('/api/memos/<int:memo_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_memo(memo_id):
|
||||
import os
|
||||
from flask import current_app
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
# 1. 암호화 여부 확인 및 파일 목록 가져오기
|
||||
c.execute('SELECT is_encrypted FROM memos WHERE id = ?', (memo_id,))
|
||||
memo = c.fetchone()
|
||||
if not memo:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Memo not found'}), 404
|
||||
|
||||
if memo['is_encrypted']:
|
||||
conn.close()
|
||||
return jsonify({'error': _t('msg_encrypted_locked')}), 403
|
||||
|
||||
# 2. 물리적 파일 삭제 준비
|
||||
c.execute('SELECT filename FROM attachments WHERE memo_id = ?', (memo_id,))
|
||||
files = c.fetchall()
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
|
||||
try:
|
||||
# 3. 물리 파일 삭제 루프
|
||||
for f in files:
|
||||
filepath = os.path.join(upload_folder, f['filename'])
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
current_app.logger.info(f"Physical file deleted on memo removal: {f['filename']}")
|
||||
|
||||
# 4. 메모 삭제 (외래 키 제약 조건에 의해 tags 등은 자동 삭제되거나 처리됨)
|
||||
# Note: attachments 테이블의 memo_id는 SET NULL 설정이므로 수동으로 레코드도 삭제해줍니다.
|
||||
c.execute('DELETE FROM attachments WHERE memo_id = ?', (memo_id,))
|
||||
c.execute('DELETE FROM memos WHERE id = ?', (memo_id,))
|
||||
|
||||
conn.commit()
|
||||
current_app.logger.info(f"Memo and its {len(files)} files deleted: ID {memo_id}")
|
||||
return jsonify({'message': 'Deleted memo and all associated files'})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@memo_bp.route('/api/memos/<int:memo_id>/decrypt', methods=['POST'])
|
||||
@login_required
|
||||
def decrypt_memo_route(memo_id):
|
||||
data = request.json
|
||||
password = data.get('password')
|
||||
if not password: return jsonify({'error': 'Password required'}), 400
|
||||
conn = get_db(); c = conn.cursor()
|
||||
c.execute('SELECT content, is_encrypted FROM memos WHERE id = ?', (memo_id,))
|
||||
memo = c.fetchone(); conn.close()
|
||||
if not memo: return jsonify({'error': 'Memo not found'}), 404
|
||||
if not memo['is_encrypted']: return jsonify({'content': memo['content']})
|
||||
decrypted = decrypt_content(memo['content'], password)
|
||||
if decrypted is None:
|
||||
current_app.logger.warning(f"Decryption FAILED: ID {memo_id}")
|
||||
return jsonify({'error': 'Invalid password'}), 403
|
||||
|
||||
current_app.logger.info(f"Decryption SUCCESS: ID {memo_id}")
|
||||
return jsonify({'content': decrypted})
|
||||
@@ -0,0 +1,58 @@
|
||||
import os
|
||||
import json
|
||||
from flask import Blueprint, request, jsonify, current_app # type: ignore
|
||||
from ..auth import login_required
|
||||
|
||||
settings_bp = Blueprint('settings', __name__)
|
||||
|
||||
CONFIG_PATH = os.path.join(os.getcwd(), 'config.json')
|
||||
|
||||
# 기본 테마 및 시스템 설정
|
||||
DEFAULT_SETTINGS = {
|
||||
"bg_color": "#0f172a",
|
||||
"sidebar_color": "rgba(30, 41, 59, 0.7)",
|
||||
"card_color": "rgba(30, 41, 59, 0.85)",
|
||||
"encrypted_border": "#00f3ff",
|
||||
"ai_accent": "#8b5cf6",
|
||||
"enable_ai": True,
|
||||
"lang": "ko"
|
||||
}
|
||||
|
||||
@settings_bp.route('/api/settings', methods=['GET'])
|
||||
@login_required
|
||||
def get_settings():
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
return jsonify(DEFAULT_SETTINGS)
|
||||
|
||||
try:
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
# 기본값과 병합하여 신규 필드 등 누락 방지
|
||||
full_data = {**DEFAULT_SETTINGS, **data}
|
||||
return jsonify(full_data)
|
||||
except Exception as e:
|
||||
return jsonify(DEFAULT_SETTINGS)
|
||||
|
||||
@settings_bp.route('/api/settings', methods=['POST'])
|
||||
@login_required
|
||||
def save_settings():
|
||||
data = request.json
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
try:
|
||||
# 기존 데이터 로드 후 병합
|
||||
current_data = {}
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
current_data = json.load(f)
|
||||
|
||||
updated_data = {**current_data, **data}
|
||||
|
||||
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(updated_data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
current_app.logger.info(f"System Settings Updated: {list(data.keys())}")
|
||||
return jsonify({'message': 'Settings saved successfully'})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -0,0 +1,58 @@
|
||||
import os
|
||||
import base64
|
||||
from cryptography.fernet import Fernet # type: ignore
|
||||
from cryptography.hazmat.primitives import hashes # type: ignore
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC # type: ignore
|
||||
|
||||
def derive_key(password: str):
|
||||
"""
|
||||
.env에 설정된 ENCRYPTION_SEED를 솔트로 사용하여 사용자 비밀번호로부터 암호화 키를 파생합니다.
|
||||
"""
|
||||
seed = os.getenv('ENCRYPTION_SEED', 'default_secret_seed_123').encode()
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=seed,
|
||||
iterations=100000,
|
||||
)
|
||||
key = base64.urlsafe_b64encode(kdf.derive(password.encode()))
|
||||
return key
|
||||
|
||||
def encrypt_content(content: str, password: str):
|
||||
"""지정된 비밀번호로 내용을 암호화합니다."""
|
||||
key = derive_key(password)
|
||||
f = Fernet(key)
|
||||
encrypted_data = f.encrypt(content.encode()).decode()
|
||||
return encrypted_data
|
||||
|
||||
def decrypt_content(encrypted_data: str, password: str):
|
||||
"""지정된 비밀번호로 암호화된 내용을 복호화합니다."""
|
||||
try:
|
||||
key = derive_key(password)
|
||||
f = Fernet(key)
|
||||
decrypted_data = f.decrypt(encrypted_data.encode()).decode()
|
||||
return decrypted_data
|
||||
except Exception:
|
||||
# 비밀번호가 틀리거나 데이터가 손상된 경우
|
||||
return None
|
||||
|
||||
def get_master_key():
|
||||
"""파일 암호화에 사용될 시스템 마스터 키를 생성합니다."""
|
||||
seed = os.getenv('ENCRYPTION_SEED', 'master_default_seed_777')
|
||||
return derive_key(seed)
|
||||
|
||||
def encrypt_file(data: bytes):
|
||||
"""데이터를 시스템 마스터 키로 암호화합니다."""
|
||||
key = get_master_key()
|
||||
f = Fernet(key)
|
||||
return f.encrypt(data)
|
||||
|
||||
def decrypt_file(data: bytes):
|
||||
"""데이터를 시스템 마스터 키로 복호화합니다."""
|
||||
try:
|
||||
key = get_master_key()
|
||||
f = Fernet(key)
|
||||
return f.decrypt(data)
|
||||
except Exception:
|
||||
# 복호화 실패 (암호화되지 않은 파일이거나 키가 다름)
|
||||
return None
|
||||
@@ -0,0 +1,33 @@
|
||||
import re
|
||||
from ..constants import GROUP_DEFAULT
|
||||
|
||||
def parse_metadata(text):
|
||||
"""
|
||||
텍스트에서 ##그룹명 과 #태그 추출 유틸리티.
|
||||
"""
|
||||
group_name = GROUP_DEFAULT
|
||||
tags = []
|
||||
|
||||
if not text:
|
||||
return group_name, tags
|
||||
|
||||
group_match = re.search(r'##(\S+)', text)
|
||||
if group_match:
|
||||
group_name = group_match.group(1)
|
||||
|
||||
tag_matches = re.finditer(r'(?<!#)#(\S+)', text)
|
||||
for match in tag_matches:
|
||||
tags.append(match.group(1))
|
||||
|
||||
return group_name, list(set(tags))
|
||||
|
||||
def extract_links(text):
|
||||
"""
|
||||
텍스트에서 [[#ID]] 형태의 내부 링크를 찾아 ID 목록(정수)을 반환합니다.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
|
||||
# [[#123]] 패턴 매칭
|
||||
links = re.findall(r'\[\[#(\d+)\]\]', text)
|
||||
return list(set([int(link_id) for link_id in links]))
|
||||
@@ -0,0 +1,41 @@
|
||||
import json
|
||||
import os
|
||||
from flask import current_app # type: ignore
|
||||
|
||||
class I18n:
|
||||
_locales = {}
|
||||
_default_lang = 'en'
|
||||
|
||||
@classmethod
|
||||
def load_locales(cls):
|
||||
locales_dir = os.path.join(current_app.static_folder, 'locales')
|
||||
for filename in os.listdir(locales_dir):
|
||||
if filename.endswith('.json'):
|
||||
lang = filename.split('.')[0]
|
||||
with open(os.path.join(locales_dir, filename), 'r', encoding='utf-8') as f:
|
||||
cls._locales[lang] = json.load(f)
|
||||
|
||||
@classmethod
|
||||
def t(cls, key, lang=None):
|
||||
if not lang:
|
||||
# 기본적으로 config에서 언어를 가져오거나 'en' 사용
|
||||
lang = current_app.config.get('lang', cls._default_lang)
|
||||
|
||||
if not cls._locales:
|
||||
cls.load_locales()
|
||||
|
||||
data = cls._locales.get(lang, cls._locales.get(cls._default_lang, {}))
|
||||
|
||||
# 중첩 키 지원 (예: "groups.done")
|
||||
parts = key.split('.')
|
||||
for p in parts:
|
||||
if isinstance(data, dict):
|
||||
data = data.get(p)
|
||||
else:
|
||||
return key
|
||||
|
||||
return data or key
|
||||
|
||||
# 숏컷 함수
|
||||
def _t(key, lang=None):
|
||||
return I18n.t(key, lang)
|
||||
@@ -0,0 +1,17 @@
|
||||
import platform
|
||||
from app import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
# OS 환경에 따른 설정 분기
|
||||
is_windows = platform.system() == "Windows"
|
||||
|
||||
# Windows(개발/디버그): 5050 포트, Linux(운영): 5093 포트
|
||||
port = 5050 if is_windows else 5093
|
||||
debug_mode = True if is_windows else False
|
||||
|
||||
print(f"📡 {'Windows' if is_windows else 'Linux'} 환경 감지 - Port: {port}, Debug: {debug_mode}")
|
||||
|
||||
# 향후 Linux 서버 구축시 gunicorn / uwsgi 로 구동 권장
|
||||
app.run(host="0.0.0.0", port=port, debug=debug_mode)
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"bg_color": "#0f172a",
|
||||
"sidebar_color": "rgba(30, 41, 59, 0.7)",
|
||||
"card_color": "rgba(30, 41, 59, 0.85)",
|
||||
"encrypted_border": "#00f3ff",
|
||||
"ai_accent": "#8b5cf6",
|
||||
"enable_ai": false,
|
||||
"lang": "en"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# 🧠 뇌사료 (Brain Dogfood) 프로젝트 문서화 (v5.0+)
|
||||
|
||||
> **"지식은 기록될 때 힘을 얻고, 연결될 때 생명을 얻는다."**
|
||||
|
||||
본 프로젝트는 개인용 지식창고 및 메모 서버로, **보안**, **지능형 연결(Nebula)**, 그리고 **기록의 습관(Heatmap)**을 핵심 가치로 삼습니다.
|
||||
|
||||
## 🚀 프로젝트 개요
|
||||
- **이름**: 뇌사료 (Brain Dogfood)
|
||||
- **핵심 가치**:
|
||||
- **지식 네뷸라 (Knowledge Nebula)**: D3.js 기반의 유기적인 지식 그래프 시각화.
|
||||
- **지식 성장 히트맵 (Growth Heatmap)**: 꾸준한 기록을 시각화하는 활동 대시보드.
|
||||
- **이중 보안**: 메모별 개별 암호화 및 미디어 보안 실드.
|
||||
- **AI 구조화**: Gemini 2.0 Flash 기반 자동 요약 및 지능형 태깅.
|
||||
|
||||
## ✨ What's New in v5.0
|
||||
- **Heatmap**: 최근 1년/6개월/3개월/1개월 활동량 지표 추가.
|
||||
- **Performance**: 대량 데이터 로딩 최적화(Bulk Fetch) 및 무한 스크롤 도입.
|
||||
- **Editor**: 중요 지식 강조를 위한 글자 색상(Color Syntax) 기능 통합.
|
||||
|
||||
## 📂 문서 인덱스
|
||||
1. [**사용자 매뉴얼 (User Manual)**](user_manual.md): **[최초 사용자 필독]** 사용법 및 연결 문법
|
||||
2. [**시스템 아키텍처 (Architecture)**](architecture.md): 폴더 구조 및 모듈형 설계
|
||||
3. [**데이터베이스 및 API 명세 (API Reference)**](api_reference.md): DB 스키마 및 확장된 API 명세
|
||||
4. [**핵심 기능 가이드 (Features)**](features.md): 히트맵 알고리즘 및 AI 인사이트 메커니즘
|
||||
5. [**로직 흐름도 (Logic Flow)**](logic_flow.md): 앱 라이프사이클 및 보안 상태 전이 규칙
|
||||
6. [**소스 매핑 가이드 (Source Mapping)**](source_mapping.md): 호출 관계 및 Ops 도구 매핑
|
||||
7. [**단축키 가이드 (Shortcuts Guide)**](shortcuts.md): **[업무 효율 극대화]** 탐색 및 편집 단축키 총정리
|
||||
|
||||
---
|
||||
*Last Updated: 2026-04-15*
|
||||
@@ -0,0 +1,44 @@
|
||||
# 📡 데이터베이스 및 API 명세서 (v13.5)
|
||||
|
||||
본 문서는 `뇌사료` 프로젝트의 데이터 저장 구조(Schema)와 모든 외부 통신 인터페이스(API)를 상세히 기술합니다.
|
||||
|
||||
## 🗄️ 1. 데이터베이스 스키마 (DB Schema)
|
||||
|
||||
### 1.1 `memos` 테이블
|
||||
메모의 핵심 데이터를 저장합니다.
|
||||
| 컬럼명 | 타입 | 기본값 | 설명 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `id` | INTEGER | PRIMARY KEY | 자동 증가 고유 아이디 |
|
||||
| `title` | TEXT | - | 메모 제목 |
|
||||
| `content` | TEXT | - | 메모 본문 (암호화 시 바이너리 텍스트) |
|
||||
| `is_encrypted` | BOOLEAN | 0 | 암호화 여부 |
|
||||
|
||||
### 1.2 `memo_links` 테이블 (v7.0 추가)
|
||||
메모 간의 `[[#ID]]` 링크 및 시각화 인력을 관리합니다.
|
||||
| 컬럼명 | 타입 | 설명 |
|
||||
| :--- | :--- | :--- |
|
||||
| `source_id` | INTEGER | 링크를 건 메모 ID |
|
||||
| `target_id` | INTEGER | 링크 대상 메모 ID |
|
||||
|
||||
---
|
||||
|
||||
## 🌐 2. API 엔드포인트 전수 명세
|
||||
|
||||
### 2.1 Memos & Analysis
|
||||
| Method | URL | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `GET` | `/api/memos` | 전체 메모 목록, 태그, 첨부파일, **백링크** 정보 통합 조회 |
|
||||
| `POST` | `/api/memos/<id>/decrypt` | 비밀번호 검증 및 본문 일시 복호화 |
|
||||
| `GET` | `/api/stats/heatmap` | 최근 N일간의 일자별 메모 작성 수(통계) 조회 (`days` 파라미터 지원) |
|
||||
|
||||
### 2.2 Assets (제한적 접근)
|
||||
| Method | URL | Security Policy | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/api/download/<filename>` | **세션 필수(로그인 상호작용)** | 이미지/파일 다운로드. 이미지인 경우 `inline` 처리 및 암호화 메모 관련 파일은 로그인 미달 시 403 차단. |
|
||||
| `POST` | `/api/upload` | `login_required` | 파일 업로드 및 서버 측 마스터 키 암호화 저장. |
|
||||
|
||||
### 2.3 Settings & Ops (v11.0 추가)
|
||||
| Method | URL | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `GET` | `/api/settings` | 서버 사이드 테마 및 전역 설정 조회 |
|
||||
| `POST` | `/api/settings` | UI 테마 설정을 서버에 영구 기록 |
|
||||
@@ -0,0 +1,38 @@
|
||||
# 🏢 시스템 아키텍처 및 폴더 구조 (v5.0+)
|
||||
|
||||
본 문서는 `뇌사료` 프로젝트의 물리적 파일 구조와 논리적 설계 아키텍처를 상세히 기술합니다.
|
||||
|
||||
## 📁 1. 폴더 구조 (Folder Structure)
|
||||
|
||||
| 경로 | 역할 | 상세 설명 |
|
||||
| :--- | :--- | :--- |
|
||||
| `/app` | **Backend Core** | Flask 애플리케이션의 핵심 로직 및 라우트 |
|
||||
| `/app/routes` | **Modular Routes** | 기능별로 분리된 API 엔드포인트 패키지 |
|
||||
| `/data` | **Database Box** | SQLite3 DB 파일 (`memos.db`) 저장 위치 |
|
||||
| `/docs` | **Documentation** | 시스템 기술 문서 및 가이드 |
|
||||
| `/logs` | **Log Box** | 시스템 작동 및 접근 로그 (`app.log`) |
|
||||
| `/static` | **Static Assets** | CSS, 이미지, 파비코 및 프론트엔드 JS |
|
||||
| `/static/js/components` | **UI Components** | D3.js 시각화 모듈 및 UI 핵심 로직 |
|
||||
| `/templates` | **HTML Templates** | Jinja2 기반 레이아웃 및 페이지 |
|
||||
| `deploy.py` | **Ops Tool** | 수술적 정밀 배포 도구 (Surgical Deployment) |
|
||||
| `backup.py` | **Disaster Recovery** | 핵심 데이터(DB, .env, 첨부파일) 증분 백업 도구 |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 2. 설계 아키텍처 (Design Architecture)
|
||||
|
||||
### 2.1 Backend: Blueprint-based Modular Flask
|
||||
- **패키지 구조**: `app/__init__.py`에서 중앙 집중식으로 앱을 생성하고, `routes/` 아래의 각 기능을 Blueprint로 등록합니다.
|
||||
- **보안 실드 (Security Shield)**: `before_request` 단계에서 비정상적인 트래픽 및 파라미터를 필터링하는 로깅 시스템이 선제적으로 작동합니다.
|
||||
- **성능 최적화 (Bulk Fetch)**: 다량의 메모리 조회 시 발생하는 N+1 문제를 방지하기 위해 태그, 첨부파일, 백링크 정보를 한꺼번에 Fetch하는 벌크 조회 로직이 적용되었습니다.
|
||||
|
||||
### 2.2 Frontend: Modular Component Architecture
|
||||
- **지식 네뷸라 (Knowledge Nebula)**: D3.js의 물리 시뮬레이션 엔진을 도입하여 유기적인 성단 구조를 시각화합니다.
|
||||
- **컴포넌트 중심 설계**: `HeatmapManager.js` (활동 시각화), `CalendarManager.js` (달력), `Visualizer.js` (그래프), `DrawerManager.js` (탐색기) 등으로 독립된 모듈 구조를 채택하여 유지보수성을 극대화했습니다.
|
||||
- **레이아웃 혁명**: **무한 스크롤(Infinite Scroll)** 페이징 기법을 도입하여 수천 개의 지식 파편도 성능 저하 없이 탐색할 수 있습니다.
|
||||
- **State Management**: `AppService.js`를 중앙 상태 관리 엔진으로 활용하여 데이터 요청과 UI 업데이트의 정합성을 유지합니다.
|
||||
|
||||
### 2.3 Ops & Reliability
|
||||
- **Merged Configuration**: 개발/운영 환경의 환경변수를 한곳에서 관리하며, 배포 시 `.env` 파일을 통해 보안 설정이 주입됩니다.
|
||||
- **Surgical Cleanup**: 배포 시 운영 데이터(DB, Uploads)는 보존하고 코드 영역만 정밀하게 교체하는 수술적 배포 방식을 채택했습니다.
|
||||
- **Disaster Recovery**: `backup.py`를 통해 서버 침해나 시스템 붕괴 시에도 3대 핵심 자산(.env, DB, Uploads)만으로 즉시 복구가 가능한 구조를 갖췄습니다.
|
||||
@@ -0,0 +1,48 @@
|
||||
# 💎 핵심 기능 가이드 (v13.3)
|
||||
|
||||
본 문서는 `뇌사료` 프로젝트를 상징하는 핵심 기능들인 **지식 시각화**, **암호화**, **AI 분석**에 대한 상세 명세를 담고 있습니다.
|
||||
|
||||
## 🌌 1. 지식 네뷸라 (Knowledge Nebula)
|
||||
D3.js v7 물리 시뮬레이션 엔진을 통해 파편화된 메모들을 유기적인 우주 성단 구조로 시각화합니다.
|
||||
|
||||
### 1.1 시각화 아키텍처
|
||||
- **엔진**: D3.js Force Simulation
|
||||
- **성단(Constellation) 로직**:
|
||||
- **그룹 인력**: 같은 그룹에 속한 메모들은 서로를 끌어당겨 하나의 별무리를 형성합니다.
|
||||
- **의미론적 연결**: 공통 태그를 공유하는 노드들 사이에 보이지 않는 인력을 설정하여 맥락이 유사한 지식들이 근접하게 배치됩니다.
|
||||
- **인터랙션**: 노드 클릭 시 상세 정보 모달이 출력되며, 마우스 호버 시 연결된 지식망이 강조(Highlight)됩니다.
|
||||
|
||||
## 🔒 2. 이중 보안 암호화 시스템 (Dual-Layer Security)
|
||||
|
||||
### 2.1 메모 및 파일 보안
|
||||
- **개별 암호화**: 메모마다 고유한 비밀번호를 사용하여 `Fernet (AES-128 CBC/HMAC)` 방식으로 본문을 암호화합니다.
|
||||
- **미디어 실드 (v10.1)**: 모든 첨부파일은 서버 마스터 키로 암호화되어 저장됩니다. 암호화된 메모의 이미지는 **로그인된 세션**에서만 정밀하게 렌더링을 허용하여 데이터 유출을 원천 차단합니다.
|
||||
|
||||
## 🧠 3. Gemini AI 기반 지식 구조화 (AI Insight)
|
||||
|
||||
### 3.1 자동 추출 및 요약
|
||||
- **학습된 페르소나**: 최신 Gemini 2.0 Flash 모델이 메모의 맥락을 분석하여 핵심 요약과 태그를 생성합니다. (.env에서 모델 식별자를 언제든 변경할 수 있습니다.)
|
||||
- **지능형 연동**: AI가 생성한 태그는 지식 네뷸라 엔진의 인력 설정에 반영되어, 사용자가 명시적으로 연결하지 않아도 관련 지식끼리 우주 상에서 가까이 부유하게 됩니다.
|
||||
|
||||
## 🔗 4. 내부 링크 및 백링크 시스템
|
||||
|
||||
### 4.1 연결 문법 (`[[#ID]]`)
|
||||
- **자동 링크**: 본문에 `[[#12]]`와 같이 입력하면 뷰어에서 클릭 가능한 링크로 변환되며, 지식 맵 상에서 두 노드 사이에 **강력한 실선**이 형성됩니다.
|
||||
- **역방향 추적 (Backlinks)**: 특정 메모 카드 하단에 해당 메모를 인용 중인 다른 메모의 목록이 노출되어, 지식의 흐름을 양방향으로 추적할 수 있습니다.
|
||||
27:
|
||||
32: ## 🌡️ 5. 지식 성장 히트맵 (Intellectual Growth Heatmap) - v14.0
|
||||
33:
|
||||
34: ### 5.1 활동 시각화
|
||||
35: - **기록 습관 형성**: 최근 365일간의 활동량을 GitHub 스타일의 그리드로 시각화하여 지식 축적의 꾸준함을 독려합니다.
|
||||
36: - **동적 범위 필터링**: 사용자의 필요에 따라 **1개월 / 3개월 / 6개월 / 1년** 단위를 자유롭게 선택하여 볼 수 있습니다.
|
||||
37: - **상태 보존**: 선택한 보기 설정은 `localStorage`에 저장되어 재접속 시에도 유지됩니다.
|
||||
38:
|
||||
39: ### 5.2 지능형 히트맵 알고리즘
|
||||
40: - **단계별 농도**: 해당 일의 메모 작성 수에 따라 5단계(`lvl-0`~`lvl-4`)의 색상 농도가 적용됩니다.
|
||||
41: - **프리미엄 그라데이션**: 뇌사료 특유의 Cyan(시안)에서 Purple(보라)로 이어지는 네온 그라데이션 테마를 따릅니다.
|
||||
42:
|
||||
43: ## 🎨 6. 확장된 에디터 스타일링 (Enhanced Editor)
|
||||
44:
|
||||
45: ### 6.1 컬러 텍스트 (Color Syntax)
|
||||
46: - **시각적 강조**: Toast UI Editor의 컬러 신택스 플러그인을 통합하여, 본문 중 중요한 지식 키워드를 다양한 색상으로 강조할 수 있습니다.
|
||||
47: - **지각적 설계**: 다크 모드 환경에서도 가독성이 뛰어난 색상 팔레트를 우선적으로 제공합니다.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 334 KiB |
@@ -0,0 +1,55 @@
|
||||
# ⚙️ 로직 흐름 및 비즈니스 규칙 (v5.0+)
|
||||
|
||||
본 문서는 `뇌사료` 프로젝트 내부에서 작동하는 특수 로직들과 데이터 처리 규칙을 상세히 설명합니다.
|
||||
|
||||
## 🔒 1. 암호화 전이 및 보안 로직
|
||||
본 시스템은 메모의 보안 상태 변화에 따른 정교한 전이 로직을 가지고 있습니다.
|
||||
|
||||
### 1.1 암호화 해제 시 상태 전이
|
||||
- **명시적 해제**: 사용자가 비밀번호를 입력하여 암호화를 해제하고 저장하는 경우, 해당 메모는 **평문(Plaintext)** 상태로 전환됩니다. 이는 사용자의 명시적 의사 결정으로 간주합니다.
|
||||
- **수정 시 유지**: 암호화된 상태에서 내용만 수정할 경우, 기존 비밀번호를 사용하여 백엔드에서 다시 암호화(Re-encrypt) 과정을 거쳐 저장됩니다.
|
||||
|
||||
### 1.2 첨부파일 접근 제어 (403 해결)
|
||||
- **인증 연동**: `/api/download` 라우트는 단순히 파일 존재 여부만 체크하지 않고, 사용자의 **세션 로그인 여부**를 확인합니다.
|
||||
- **인라인 표시**: 이미지 파일의 경우 `Content-Disposition: inline` 헤더를 강제하여 브라우저 마크다운 본문 내에서 선명하게 렌더링되도록 지원합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🌌 2. 지식 네뷸라 성단(Constellation) 로직
|
||||
D3.js 엔진은 데이터 간의 명시적 링크 외에도 의미론적 연결을 자동으로 시뮬레이션합니다.
|
||||
|
||||
### 2.1 자동 연결 규칙 (Semantic Linking)
|
||||
1. **명시적 링크**: `[[#ID]]` 패턴으로 작성된 내부 링크 (실선 표시).
|
||||
2. **동일 그룹**: 같은 그룹에 속한 메모들끼리 부유하며 성단을 형성 (은은한 연결선).
|
||||
3. **공통 태그**: 같은 태그를 공유하는 메모들 사이에 인력이 작용하여 근접 배치.
|
||||
|
||||
### 2.2 이미지 경로 보정 (Path Resolution)
|
||||
- **후처리 로직**: 마크다운 본문의 `img src="photo.png"`와 같은 상대 경로를 `fixImagePaths` 유틸리티가 감지하여 `/api/download/photo.png`로 자동 보정합니다.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🔄 3. 애플리케이션 라이프사이클 및 데이터 흐름
|
||||
### 3.1 초기화 및 갱신 사이클 (App Cycle)
|
||||
1. **DOM 로드**: `DOMContentLoaded` 발생 시 모든 매니저(`Editor`, `Heatmap`, `Visualizer` 공히)가 `init()`을 거칩니다.
|
||||
2. **데이터 호출**: `AppService.refreshData()`가 실행되어 첫 페이지 메모를 가져옵니다.
|
||||
3. **병렬 동기화**: 메모 데이터 수신 시 `HeatmapManager.refresh()`가 별도 API를 호출하여 최근 1년(또는 선택 기간)의 통계를 가져와 렌더링합니다.
|
||||
|
||||
### 3.2 검색 및 실시간 필터링 (Debounce)
|
||||
- **지연 처리**: 검색창 입력 시 300ms의 `searchTimer`가 작동하여 불필요한 API 요청을 최소화합니다.
|
||||
- **상태 통합**: 검색어는 그룹/날짜 필터와 결합되어 중앙 상태(`AppService.state`)에서 관리됩니다.
|
||||
|
||||
### 3.3 무한 스크롤 및 페이징 (Infinite Scroll)
|
||||
- **오프셋 제어**: `this.state.offset`을 통해 현재 로드된 개수를 추적하며, 하단 도달 시 `limit` 단위로 다음 데이터를 추가 로드합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 4. 정밀 배포 및 재난 복구 로직
|
||||
|
||||
### 4.1 수술적 정리 (Surgical Cleanup)
|
||||
- **보호 대상**: `.env`, `data/`, `static/uploads/`, `memos.db` 등 사용자의 생성 데이터는 삭제 목록에서 철저히 제외됩니다.
|
||||
- **정밀 삭제**: 부모 폴더(예: `static`)를 지우지 않고 그 내부의 코드(JS, CSS)만 선별 삭제하여, 하위의 보호 폴더(`uploads`) 유실을 방지합니다.
|
||||
|
||||
### 4.2 핵심 자산 백업 (Disaster Recovery)
|
||||
- **3대 요소**: DB, 첨부파일, 환경설정(.env)을 하나의 압축파일(`tar.gz`)로 통합합니다. 이 세 가지만 있으면 서버가 완전히 붕괴되어도 다른 환경에서 즉시 복호화 및 서비스 재개가 가능합니다.
|
||||
@@ -0,0 +1,32 @@
|
||||
# ⌨️ 단축키 가이드 (Keyboard Shortcuts)
|
||||
|
||||
'뇌사료'를 더욱 빠르고 효율적으로 활용하기 위한 단축키 일람입니다.
|
||||
**Ctrl + Shift**를 주력으로 하며, 전설적인 **Quake 스타일**의 새 메모 단축키를 추가 지원합니다.
|
||||
|
||||
## 🚀 전역 단축키 (Global Navigation)
|
||||
어느 화면에서나 즉시 실행됩니다.
|
||||
|
||||
- **`Alt + `** (Backtick): ⚡ **Quake 스타일 새 메모** (가장 빠른 작성기 호출)
|
||||
- **`Ctrl + Shift + N`**: 📝 **새 메모 작성**
|
||||
- **`Ctrl + Shift + G`**: 🕸️ **지식 네뷸라** (시각화 모달 열기)
|
||||
- **`Ctrl + Shift + E`**: 🔍 **지식 탐색기** (사이드바 드로어 열기)
|
||||
- **`Ctrl + Shift + C`**: 📅 **캘린더 토글** (사이드바 미니 달력)
|
||||
- **`Ctrl + Shift + Q`** 또는 **`ESC`**: 🚪 **닫기**
|
||||
|
||||
---
|
||||
|
||||
## ✍️ 메모 편집 (Editor Mode)
|
||||
- **`Ctrl + Enter`** 또는 **`Ctrl + S`**: 💾 **저장 및 게시**
|
||||
- **`/` (Slash)**: 🪄 **슬래시 명령**
|
||||
- **`Shift + ESC`**: 🗑️ **작성 취소**
|
||||
|
||||
---
|
||||
|
||||
## 🖱️ 마우스 인터렉션
|
||||
- **Alt + 클릭**: 🪄 **즉시 수정**
|
||||
- **클릭**: 상세 보기
|
||||
|
||||
---
|
||||
|
||||
> [!TIP]
|
||||
> - **`Alt + `** 단축키는 게임 'Quake'의 콘솔창 호출 방식에서 유래한 것으로, 영감이 떠오른 순간 가장 빠르게 기록을 시작할 수 있는 방법입니다.
|
||||
@@ -0,0 +1,42 @@
|
||||
# 🔗 소스 매핑 및 호출 관계 (v13.4)
|
||||
|
||||
본 문서는 프론트엔드 컴포넌트와 백엔드 API, 그리고 내부 함수 간의 호출 관계와 인터페이스를 기술합니다.
|
||||
|
||||
## 📱 1. 프론트엔드 모듈간 관계
|
||||
|
||||
| 모듈 (Component) | 기능 설명 | 주요 호출 (Callee) |
|
||||
| :--- | :--- | :--- |
|
||||
| `app.js` | **Orchestrator** | `Visualizer`, `DrawerManager`, `ModalManager`, `API` |
|
||||
| `Visualizer.js` | **Nebula Engine** | `D3.js`, `ModalManager.open()`, `AppService` |
|
||||
| `DrawerManager.js` | **Explorer** | `AppService.filterMemos()`, `ModalManager` |
|
||||
| `ModalManager.js` | **Viewer** | `utils.parseInternalLinks()`, `utils.fixImagePaths()` |
|
||||
| `ComposerManager.js` | **Editor** | `API.saveMemo()`, `AttachmentBox` |
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 2. 백엔드 핵심 함수 매핑
|
||||
|
||||
### 2.1 보안 및 유틸리티
|
||||
| 함수명 | 모듈 | 역할 | 호출자 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `decrypt_file` | `app/security.py` | 첨부파일 물리 복호화 | `file.py:download_file` |
|
||||
| `extract_links` | `app/utils.py` | `[[#ID]]` 패턴 추출 | `memo.py:create/update` |
|
||||
|
||||
### 2.2 운영 도구 (Ops)
|
||||
| 파일명 | 역할 | 주요 로직 |
|
||||
| :--- | :--- | :--- |
|
||||
| `deploy.py` | **정밀 배포** | SSH/SFTP 기반 Surgical Cleanup & Upload |
|
||||
| `backup.py` | **백업** | 핵심 자산(.env, DB, Uploads) Tarball 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 🌐 3. 클라이언트-서버 통신 파라미터 (API Flow)
|
||||
|
||||
### 3.1 `GET /api/download/<filename>` (보안 하향 링크)
|
||||
- **Caller**: `ModalManager.js` (Inline Images) or `AttachmentBox.js`
|
||||
- **Security**: `session['logged_in']` 확인 -> `is_encrypted` 상태에 따른 접근 제어.
|
||||
- **Header**: 이미지인 경우 `Content-Disposition: inline`.
|
||||
|
||||
### 3.2 `PUT /api/memos/<id>` (수정 및 보안 전이)
|
||||
- **Status Change**: 암호화 해제 저장 시 `is_encrypted: 0`으로 DB 상태 업데이트.
|
||||
- **Process**: `memo.py:update_memo`에서 `password` 유무에 따른 Re-encryption 수행.
|
||||
@@ -0,0 +1,113 @@
|
||||
# 📔 뇌사료 (Brain Dogfood) 사용자 매뉴얼 (v5.0+)
|
||||
|
||||
'뇌사료' 프로젝트에 오신 것을 환영합니다! 본 매뉴얼은 파편화된 영감을 체계적인 지식 성단(Nebula)으로 구축하는 데 필요한 모든 가이드를 제공합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🌌 1. 지식 네뷸라 (시각화 탐색)
|
||||
|
||||
메인 전면에 펼쳐진 **지식 네뷸라**는 단순한 목록이 아닌 지식의 유기적인 지도를 보여줍니다.
|
||||
|
||||
- **노드(Node)**: 각각의 메모를 상징합니다.
|
||||
- **크기**: 내용이 많거나 연결이 많을수록 노드가 거대해집니다.
|
||||
- **색상**: 각 메모에 설정된 고유한 그룹 색상을 따릅니다.
|
||||
- **🔒 아이콘**: 암호화된 메모임을 나타내며, 제목만 미리보기로 제공됩니다.
|
||||
- **링크(Link)**:
|
||||
- **실선**: `[[#ID]]` 문법으로 명시적으로 연결된 관계입니다.
|
||||
- **인력(Gravity)**: 같은 그룹이나 공통 태그를 가진 메모들은 서로를 끌어당겨 가까이 배치됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 🌡️ 3. 지식 성장 히트맵 (Heatmap) 사용법
|
||||
|
||||
사이드바에 위치한 히트맵은 사용자의 기록 강도를 시각적으로 보여줍니다.
|
||||
|
||||
- **기간 전환**: 히트맵 상단 제목 옆의 드롭다운을 통해 **1개월 / 3개월 / 6개월 / 1년** 단위를 선택할 수 있습니다.
|
||||
- **상태 유지**: 한 번 선택한 기간은 브라우저에 저장되어 다음 접속 시에도 그대로 유지됩니다.
|
||||
- **활동량 확인**: 각 칸에 마우스를 올리면 해당 날짜에 작성된 메모의 개수를 확인할 수 있습니다. 색상이 진해질수록(Cyan -> Purple) 더 많은 지식을 축적했음을 의미합니다.
|
||||
|
||||
---
|
||||
|
||||
## ✍️ 4. 메모 작성 및 스타일링
|
||||
|
||||
### 4.1 지식 연결 문법 (`[[#ID]]`)
|
||||
메모 간의 명시적인 지식을 연결하려면 본문에 샵(#) 기호와 메모 번호를 사용하세요.
|
||||
> 예: "이 개념은 `[[#12]]`에서 다룬 내용과 상충됩니다."
|
||||
- **효과**: 뷰어에서 클릭 시 해당 메모로 바로 이동하며, 지식 네뷸라 상에 강력한 연결선이 형성됩니다.
|
||||
|
||||
### 4.2 컬러 텍스트 (Color Syntax)
|
||||
에디터 상단 툴바의 **색상 선택 아이콘**을 사용하여 텍스트에 색상을 입힐 수 있습니다. 중요 키워드를 강조하여 지식의 가독성을 높이세요.
|
||||
|
||||
### 4.3 개별 메모 암호화
|
||||
중요한 개인 정보나 아이디어는 암호화하여 보호할 수 있습니다.
|
||||
- **사용법**: 편집기 하단의 **[암호화 사용]** 체크 -> 비밀번호 설정.
|
||||
- **특약**: 암호화된 메모는 서버 측에서도 해독이 불가능하며, 비밀번호 분실 시 복구가 절대 불가능하므로 주의하세요.
|
||||
- **복호화**: 작성된 암호화 메모 옆의 **🔓 해독** 버튼을 눌러 비밀번호 입력 시 일시적으로 내용을 확인할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🧠 5. AI 인텔리전스 (AI Insights)
|
||||
|
||||
### 5.1 AI 활성화 및 API 키 설정 (초보자 가이드)
|
||||
'뇌사료'의 지능형 기능을 사용하려면 Google의 Gemini API 키가 필요합니다. 다음 단계에 따라 **1분 만에 무료로** 설정을 마칠 수 있습니다.
|
||||
|
||||
1. **키 발급**: [Google AI Studio (https://aistudio.google.com/app/apikey)](https://aistudio.google.com/app/apikey)에 접속합니다.
|
||||
2. **프로젝트 생성**: "Create API key in new project" 버튼을 누릅니다.
|
||||
3. **키 복사**: 생성된 `AIza...`로 시작하는 긴 문자열을 복사합니다.
|
||||
4. **서버 적용**: 본 프로젝트 폴더의 `.env` 파일을 열고 `GEMINI_API_KEY=` 뒤에 복사한 키를 붙여넣고 저장합니다.
|
||||
5. **활성화**: 앱 우상단 **[⚙️ 설정]** -> **AI 기능 활성화** 체크박스를 켜고 저장합니다.
|
||||
|
||||
> [!TIP]
|
||||
> - API 키 발급은 완전히 무료이며, 개인적인 용도로는 충분한 할당량이 제공됩니다.
|
||||
> - 키가 없더라도 메모 작성 및 시각화 등 기본적인 기능은 "NO AI" 모드로 완벽하게 작동합니다.
|
||||
|
||||
### 5.2 주요 기능
|
||||
- **자동 요약**: 방대한 내용을 AI가 1~2문장의 핵심 문장으로 압축해줍니다.
|
||||
- **스마트 태그**: 본문을 분석하여 자동으로 추천 태그를 생성합니다.
|
||||
- **추론형 배치**: AI가 생성한 태그를 기반으로 지식 네뷸라 상에서 비슷한 맥락의 메모들이 자동으로 성단을 형성합니다.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## ⌨️ 6. 단축키 및 작업 효율 (Shortcuts)
|
||||
|
||||
'뇌사료'는 마우스 없이도 거의 모든 작업을 수행할 수 있도록 강력한 **Ctrl 기반** 단축키를 지원합니다.
|
||||
|
||||
### 6.1 전역 내비게이션
|
||||
- **`Alt + `** (Backtick): ⚡ **Quake 스타일 새 메모** (영감을 즉시 기록)
|
||||
- **`Ctrl + Shift + N`**: 새 메모 작성기 열기 📝
|
||||
- **`Ctrl + Shift + G`**: 지식 네뷸라(시각화) 열기 🕸️
|
||||
- **`Ctrl + Shift + E`**: 지식 탐색기(사이드바) 열기 🔍
|
||||
- **`Ctrl + Shift + C`**: 사이드바 캘린더 토글 📅
|
||||
- **`Ctrl + Shift + Q`** 또는 **`ESC`**: 모든 모달 및 드로어 닫기
|
||||
|
||||
### 6.2 에디터 작업
|
||||
- **`Ctrl + Enter`** 또는 **`Ctrl + S`**: **현재 메모 저장 및 게시** 💾
|
||||
- **`/` (Slash)**: 슬래시 명령 오픈 (AI 요약, 서식 등) 🪄
|
||||
- **`Shift + ESC`**: 작성 취소 및 닫기
|
||||
|
||||
### 6.3 마우스 팁
|
||||
- **`Alt + 클릭`**: 메인 그리드에서 메모를 즉시 수정 ✏️
|
||||
|
||||
---
|
||||
|
||||
## 🚀 7. 운영 및 관리 (Ops & Backup)
|
||||
|
||||
### 7.1 정밀 배포 (`deploy.py`)
|
||||
개발 환경에서 작업한 코드를 서버로 안전하게 배포합니다.
|
||||
```bash
|
||||
python deploy.py
|
||||
```
|
||||
|
||||
### 7.2 재난 복구 백업 (`backup.py`)
|
||||
서버의 모든 핵심 데이터를 압축하여 안전하게 보관합니다.
|
||||
```bash
|
||||
python backup.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**지식의 우주를 마음껏 탐험하세요!** 🛸🌌
|
||||
@@ -0,0 +1,49 @@
|
||||
# 📱 v2.0 모바일 최적화 로드맵 (Roadmap)
|
||||
|
||||
본 문서는 `뇌사료` 프로젝트 v2.0에서 다룰 모바일 해상도 지원 및 UX 최적화 범위에 대한 정밀 분석 내역을 담고 있습니다.
|
||||
|
||||
## 1. 레이아웃 및 응답성 (Responsive Layout)
|
||||
|
||||
### 1.1 오프캔버스 사이드바 (Off-canvas Sidebar)
|
||||
- **현황**: 사이드바가 가로 공간을 상시 점유하여 모바일에서 가독성 저해.
|
||||
- **v2.0 목표**: 768px 이하에서 사이드바를 기본적으로 숨기고, 상단 햄버거 메뉴 버튼 클릭 시 왼쪽에서 슬라이드되는 드로어(Drawer) 방식으로 전환.
|
||||
- **기술적 구현**: `transform: translateX(-100%)` 및 `z-index`를 활용한 오버레이 처리.
|
||||
|
||||
### 1.2 가변형 메모 그리드 (Fluid Masonry Grid)
|
||||
- **현황**: 열 개수가 고정되거나 모바일에서 너무 좁게 보임.
|
||||
- **v2.0 목표**: 모바일 해상도에서 메모 카드를 1열(100%)로 강제하여 텍스트 가독성 극대화. 패딩 값을 `3rem`에서 `1rem`으로 축소.
|
||||
|
||||
---
|
||||
|
||||
## 2. 입력 및 작성 경험 (Composer UX)
|
||||
|
||||
### 2.1 인터페이스 수직 스태킹 (Vertical Stacking)
|
||||
- **현황**: 제목, 그룹, 태그 입력창이 가로로 배치되어 모바일에서 잘림.
|
||||
- **v2.0 목표**: `flex-direction: column`을 적용하여 제목 -> 메타 정보(그룹/태그) -> 에디터 순서로 자연스럽게 흐르도록 재배치.
|
||||
|
||||
### 2.2 모바일 에디터 최적화
|
||||
- **v2.0 목표**: Toast UI 에디터의 불필요한 툴바를 숨기고, 모바일 자판이 올라와도 입력 영역이 충분히 확보되도록 높이 자동 조절 기능 추가.
|
||||
|
||||
---
|
||||
|
||||
## 3. 터치 및 제스처 (Touch & Gestures)
|
||||
|
||||
### 3.1 터치 타겟(Touch Target) 확장
|
||||
- **목표**: 모든 버튼 및 상호작용 요소의 크기를 최소 44x44px 이상으로 확보하여 오클릭 방지.
|
||||
- **세부 사항**: 삭제, 수정, 암호화 토글 버튼의 여백 조정.
|
||||
|
||||
### 3.2 제스처 지원
|
||||
- **목표**: 화면 왼쪽 가장자리를 스와이프하여 사이드바를 여는 제스처 로직 검토.
|
||||
|
||||
---
|
||||
|
||||
## 4. 기술적 체크리스트 (Technical Checklist)
|
||||
|
||||
- [ ] `@media (max-width: 768px)` 기준점 확립.
|
||||
- [ ] 터치 이벤트(`touchstart`, `touchend`) 처리 최적화.
|
||||
- [ ] `safe-area-inset-bottom` 등 최신 모바일 기기의 노치 및 하단 바 대응.
|
||||
- [ ] 모바일 데이터 환경을 고려한 이미지 지연 로딩(Lazy Loading) 적용.
|
||||
- [ ] 모달 창의 `width: 100%; height: 100%;` 전체 화면 모드 지원.
|
||||
|
||||
---
|
||||
*Generated for Brain Dogfood v2.0 Development*
|
||||
@@ -0,0 +1,6 @@
|
||||
Flask==3.0.2
|
||||
google-genai
|
||||
python-dotenv==1.0.1
|
||||
cryptography==42.0.5
|
||||
paramiko==3.4.0
|
||||
scp==0.14.5
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 뇌사료 메인 엔트리 포인트 (v5.0 리팩토링 완료)
|
||||
*/
|
||||
import { API } from './js/api.js';
|
||||
import { UI } from './js/ui.js';
|
||||
import { AppService } from './js/AppService.js';
|
||||
import { EditorManager } from './js/editor.js';
|
||||
import { ComposerManager } from './js/components/ComposerManager.js';
|
||||
import { CalendarManager } from './js/components/CalendarManager.js';
|
||||
import { Visualizer } from './js/components/Visualizer.js';
|
||||
import { HeatmapManager } from './js/components/HeatmapManager.js';
|
||||
import { DrawerManager } from './js/components/DrawerManager.js';
|
||||
import { ModalManager } from './js/components/ModalManager.js';
|
||||
import { I18nManager } from './js/utils/I18nManager.js';
|
||||
import { Constants } from './js/utils/Constants.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// --- 🔹 Initialization ---
|
||||
await UI.initSettings(); // ⭐ i18n 및 테마 로딩 완료까지 최우선 대기
|
||||
EditorManager.init('#editor');
|
||||
|
||||
// 작성기 초기화 (저장 성공 시 데이터 새로고침 콜백 등록)
|
||||
ComposerManager.init(() => AppService.refreshData(updateSidebarCallback));
|
||||
|
||||
HeatmapManager.init('heatmapContainer'); // 히트맵 초기화
|
||||
DrawerManager.init();
|
||||
Visualizer.init('graphContainer');
|
||||
UI.initSidebarToggle();
|
||||
|
||||
// --- 🔹 Callbacks ---
|
||||
const updateSidebarCallback = (memos, activeGroup) => {
|
||||
UI.updateSidebar(memos, activeGroup, (newFilter) => {
|
||||
if (newFilter === Constants.GROUPS.FILES) {
|
||||
ModalManager.openAssetLibrary((id, ms) => UI.openMemoModal(id, ms));
|
||||
} else {
|
||||
AppService.setFilter({ group: newFilter }, updateSidebarCallback);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 달력 초기화
|
||||
CalendarManager.init('calendarContainer', (date) => {
|
||||
AppService.setFilter({ date }, updateSidebarCallback);
|
||||
});
|
||||
|
||||
// 무한 스크롤 초기화
|
||||
UI.initInfiniteScroll(() => {
|
||||
AppService.loadMore(updateSidebarCallback);
|
||||
});
|
||||
|
||||
// 드래그 앤 드롭 파일 탐지
|
||||
EditorManager.bindDropEvent('.composer-wrapper', (shouldOpen) => {
|
||||
if (shouldOpen && ComposerManager.DOM.composer.style.display === 'none') {
|
||||
ComposerManager.openEmpty();
|
||||
}
|
||||
});
|
||||
|
||||
// --- 🔹 Global Event Handlers for Memo Cards ---
|
||||
window.memoEventHandlers = {
|
||||
onEdit: (id) => {
|
||||
const memo = AppService.state.memosCache.find(m => m.id == id);
|
||||
ComposerManager.openForEdit(memo);
|
||||
},
|
||||
onDelete: async (id) => {
|
||||
if (confirm(I18nManager.t('msg_delete_confirm'))) {
|
||||
await API.deleteMemo(id);
|
||||
AppService.refreshData(updateSidebarCallback);
|
||||
}
|
||||
},
|
||||
onAI: async (id) => {
|
||||
UI.showLoading(true);
|
||||
try {
|
||||
await API.triggerAI(id);
|
||||
await AppService.refreshData(updateSidebarCallback);
|
||||
} catch (err) { alert(err.message); }
|
||||
finally { UI.showLoading(false); }
|
||||
},
|
||||
onTogglePin: async (id) => {
|
||||
const memo = AppService.state.memosCache.find(m => m.id == id);
|
||||
await API.saveMemo({ is_pinned: !memo.is_pinned }, id);
|
||||
AppService.refreshData(updateSidebarCallback);
|
||||
},
|
||||
onToggleStatus: async (id) => {
|
||||
const memo = AppService.state.memosCache.find(m => m.id == id);
|
||||
const newStatus = memo.status === 'done' ? 'active' : 'done';
|
||||
await API.saveMemo({ status: newStatus }, id);
|
||||
AppService.refreshData(updateSidebarCallback);
|
||||
},
|
||||
onOpenModal: (id) => UI.openMemoModal(id, AppService.state.memosCache),
|
||||
onUnlock: async (id) => {
|
||||
const password = prompt(I18nManager.t('prompt_password'));
|
||||
if (!password) return;
|
||||
try {
|
||||
const data = await API.decryptMemo(id, password);
|
||||
const memo = AppService.state.memosCache.find(m => m.id == id);
|
||||
if (memo) {
|
||||
memo.content = data.content;
|
||||
memo.is_encrypted = false;
|
||||
memo.was_encrypted = true;
|
||||
memo.tempPassword = password;
|
||||
// 검색 필터 적용 (현재 데이터 기준)
|
||||
UI.renderMemos(AppService.state.memosCache, {}, window.memoEventHandlers, false);
|
||||
}
|
||||
} catch (err) { alert(err.message); }
|
||||
}
|
||||
};
|
||||
|
||||
// --- 🔹 Search & Graph ---
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
let searchTimer;
|
||||
searchInput.oninput = () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
AppService.setFilter({ query: searchInput.value }, updateSidebarCallback);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
document.getElementById('openGraphBtn').onclick = () => {
|
||||
document.getElementById('graphModal').classList.add('active');
|
||||
setTimeout(() => {
|
||||
Visualizer.render(AppService.state.memosCache, (id) => {
|
||||
document.getElementById('graphModal').classList.remove('active');
|
||||
UI.openMemoModal(id, AppService.state.memosCache);
|
||||
});
|
||||
}, 150);
|
||||
};
|
||||
|
||||
document.getElementById('closeGraphBtn').onclick = () => {
|
||||
document.getElementById('graphModal').classList.remove('active');
|
||||
};
|
||||
|
||||
document.getElementById('openExplorerBtn').onclick = () => {
|
||||
DrawerManager.open(AppService.state.memosCache, AppService.state.currentFilterGroup, (filter) => {
|
||||
AppService.setFilter({ group: filter }, updateSidebarCallback);
|
||||
});
|
||||
};
|
||||
|
||||
// --- 🔹 Global Shortcuts (Comprehensive Shift to Ctrl-based System) ---
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const isCtrl = e.ctrlKey || e.metaKey;
|
||||
const isAlt = e.altKey;
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
// 1. ESC: 모든 창 닫기
|
||||
if (e.key === 'Escape') {
|
||||
document.querySelectorAll('.modal.active, .drawer.active').forEach(el => el.classList.remove('active'));
|
||||
if (ComposerManager.DOM.composer.style.display === 'block') ComposerManager.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Ctrl + Enter / Ctrl + S: 저장 (작성기 열려있을 때)
|
||||
if (isCtrl && (key === 'enter' || key === 's')) {
|
||||
if (ComposerManager.DOM.composer.style.display === 'block') {
|
||||
e.preventDefault();
|
||||
ComposerManager.handleSave(updateSidebarCallback);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Ctrl + Shift + Key 조합들 (네비게이션)
|
||||
if (isCtrl && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
switch (key) {
|
||||
case 'n': // 새 메모
|
||||
ComposerManager.openEmpty();
|
||||
break;
|
||||
case 'g': // 지식 네뷸라
|
||||
document.getElementById('openGraphBtn').click();
|
||||
break;
|
||||
case 'e': // 지식 탐색기
|
||||
document.getElementById('openExplorerBtn').click();
|
||||
break;
|
||||
case 'c': // 캘린더 토글
|
||||
CalendarManager.isCollapsed = !CalendarManager.isCollapsed;
|
||||
localStorage.setItem('calendar_collapsed', CalendarManager.isCollapsed);
|
||||
CalendarManager.updateCollapseUI();
|
||||
break;
|
||||
case 'q': // 닫기
|
||||
document.querySelectorAll('.modal.active, .drawer.active').forEach(el => el.classList.remove('active'));
|
||||
ComposerManager.close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Quake-style Shortcut: Alt + ` (새 메모)
|
||||
if (isAlt && key === '`') {
|
||||
e.preventDefault();
|
||||
ComposerManager.openEmpty();
|
||||
}
|
||||
});
|
||||
|
||||
// --- 🔹 App Start ---
|
||||
AppService.refreshData(updateSidebarCallback);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/* Composer & Editor */
|
||||
.composer-wrapper { width: 100%; margin-bottom: 3rem; }
|
||||
|
||||
#composer input[type="text"] {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: white;
|
||||
font-family: var(--font);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.meta-field {
|
||||
background: rgba(0,0,0,0.3) !important;
|
||||
border: 1px solid rgba(255,255,255,0.1) !important;
|
||||
border-radius: 8px;
|
||||
padding: 5px 10px !important;
|
||||
font-size: 0.8rem !important;
|
||||
color: var(--muted) !important;
|
||||
}
|
||||
|
||||
.editor-resize-wrapper {
|
||||
resize: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 200px;
|
||||
height: 350px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.toastui-editor-defaultUI {
|
||||
height: 100% !important;
|
||||
border: 1px solid rgba(255,255,255,0.1) !important;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#editorAttachments {
|
||||
width: 100%;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* --- 키보드 단축키 힌트 바 --- */
|
||||
.shortcut-hint-bar {
|
||||
margin-top: 10px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shortcut-toggle-btn {
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
border: 1px solid rgba(56, 189, 248, 0.15);
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
padding: 5px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-family: var(--font);
|
||||
}
|
||||
.shortcut-toggle-btn:hover {
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.shortcut-details {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 10px 4px;
|
||||
animation: fadeSlideIn 0.2s ease;
|
||||
}
|
||||
|
||||
.shortcut-details .sk {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shortcut-details kbd {
|
||||
display: inline-block;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-size: 0.68rem;
|
||||
font-family: 'Inter', monospace;
|
||||
color: var(--accent);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
@keyframes fadeSlideIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/* 🌡️ Heatmap Component */
|
||||
|
||||
.heatmap-wrapper {
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.heatmap-header {
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heatmap-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.heatmap-select {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--muted);
|
||||
font-size: 0.65rem;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.heatmap-select:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.heatmap-select option {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.heatmap-grid {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(7, 10px);
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 10px;
|
||||
gap: 3px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for cleaner look */
|
||||
.heatmap-grid::-webkit-scrollbar {
|
||||
height: 3px;
|
||||
}
|
||||
.heatmap-grid::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.heatmap-cell {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
transition: transform 0.2s, filter 0.2s;
|
||||
}
|
||||
|
||||
.heatmap-cell:hover {
|
||||
transform: scale(1.3);
|
||||
z-index: 10;
|
||||
filter: brightness(1.2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.heatmap-cell.out {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Level Colors (Cyan to Purple Gradient) */
|
||||
.heatmap-cell.lvl-0 { background: rgba(255, 255, 255, 0.05); }
|
||||
.heatmap-cell.lvl-1 { background: rgba(56, 189, 248, 0.3); }
|
||||
.heatmap-cell.lvl-2 { background: rgba(56, 189, 248, 0.6); }
|
||||
.heatmap-cell.lvl-3 { background: rgba(56, 189, 248, 0.9); }
|
||||
.heatmap-cell.lvl-4 {
|
||||
background: var(--ai-accent);
|
||||
box-shadow: 0 0 5px var(--ai-accent);
|
||||
}
|
||||
|
||||
.heatmap-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
font-size: 0.65rem;
|
||||
color: var(--muted);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.heatmap-legend .heatmap-cell {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/* Masonry Grid Layout */
|
||||
.masonry-grid { width: 100%; columns: auto 300px; column-gap: 1.5rem; }
|
||||
|
||||
/* Memo Card Styling */
|
||||
.memo-card {
|
||||
break-inside: avoid; margin-bottom: 1.5rem; background: rgba(30, 41, 59, 0.6);
|
||||
border-radius: 12px; padding: 1.2rem 1.2rem 45px 1.2rem; border: 1px solid rgba(255,255,255,0.05);
|
||||
position: relative; transition: transform 0.2s, background 0.2s;
|
||||
max-height: 320px; overflow: hidden;
|
||||
}
|
||||
|
||||
.memo-card:hover { transform: translateY(-4px); background: rgba(30, 41, 59, 1); }
|
||||
|
||||
.memo-card.compact { max-height: 80px; background: rgba(30, 41, 59, 0.3); border: 1px dashed rgba(255,255,255,0.1); }
|
||||
.memo-card.compact .memo-content,
|
||||
.memo-card.compact .memo-ai-summary,
|
||||
.memo-card.compact .memo-backlinks { display: none; }
|
||||
.memo-card.compact .memo-title { font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 0px; margin-right: 50px; }
|
||||
.memo-card.compact::after { display: none; }
|
||||
|
||||
.memo-card.encrypted {
|
||||
border: 1px solid var(--encrypted-border) !important;
|
||||
box-shadow: 0 0 15px rgba(0, 243, 255, 0.15), inset 0 0 5px rgba(0, 243, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Fade-out for long content */
|
||||
.memo-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0; left: 0; width: 100%; height: 60px;
|
||||
background: linear-gradient(transparent, rgba(15, 23, 42, 0.8));
|
||||
pointer-events: none;
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
/* Memo Content Elements */
|
||||
.memo-title { font-weight: 600; font-size: 1.0rem; margin-bottom: 0.5rem; }
|
||||
.memo-content { font-size: 0.9rem; color: #cbd5e1; line-height: 1.6; }
|
||||
.memo-content img { max-width: 100%; border-radius: 8px; margin-top: 5px; }
|
||||
|
||||
.memo-summary, .memo-ai-summary {
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
color: var(--ai-accent);
|
||||
border-left: 2px solid var(--ai-accent);
|
||||
padding-left: 10px;
|
||||
margin-bottom: 15px;
|
||||
line-height: 1.5;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.memo-summary strong {
|
||||
color: var(--ai-accent);
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
/* Meta & Badges */
|
||||
.memo-meta { margin-bottom: 12px; display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.tag-badge { padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: bold; }
|
||||
.tag-user { background: rgba(56, 189, 248, 0.2); color: var(--accent); }
|
||||
.tag-ai { background: rgba(139, 92, 246, 0.2); color: #c084fc; border: 1px solid rgba(139, 92, 246, 0.3); }
|
||||
.group-badge { background: rgba(255,255,255,0.05); color: #cbd5e1; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: bold; }
|
||||
|
||||
/* Links & Actions */
|
||||
.memo-backlinks { margin-top: 12px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.05); font-size: 0.8rem; color: var(--muted); }
|
||||
.link-item { color: var(--accent); cursor: pointer; text-decoration: none; font-weight: 600; }
|
||||
.link-item:hover { text-decoration: underline; }
|
||||
|
||||
.memo-actions { position: absolute; bottom: 10px; right: 10px; opacity: 0; transition: opacity 0.2s; display: flex; gap: 5px; }
|
||||
.memo-card:hover .memo-actions { opacity: 1; }
|
||||
|
||||
/* Attachments */
|
||||
.memo-attachments {
|
||||
margin-top: 15px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(255,255,255,0.05);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
color: #cbd5e1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.file-chip:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/* Modals & Special Overlay Components */
|
||||
|
||||
/* Asset Manager / Library */
|
||||
.asset-card {
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.asset-card:hover {
|
||||
transform: scale(1.02);
|
||||
background: rgba(255,255,255,0.1) !important;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Settings Modal Specifics */
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.settings-grid label { font-size: 0.95rem; font-weight: 600; color: #cbd5e1; }
|
||||
|
||||
.settings-grid input[type="color"] {
|
||||
width: 50px; height: 32px; padding: 0;
|
||||
border: 2px solid rgba(255,255,255,0.1); border-radius: 6px;
|
||||
background: transparent; cursor: pointer; outline: none;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex; justify-content: flex-end; gap: 10px;
|
||||
margin-top: 25px; padding-top: 15px;
|
||||
border-top: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
/* Universal Modal Close Button */
|
||||
.close-modal-btn {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
z-index: 10;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.close-modal-btn:hover {
|
||||
color: var(--accent);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Explorer Chip Counts */
|
||||
.chip-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
margin-left: 6px;
|
||||
min-width: 18px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.explorer-chip:hover .chip-count {
|
||||
background: var(--accent);
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
/* AI Disabled State */
|
||||
body.ai-disabled .ai-btn,
|
||||
body.ai-disabled .ai-summary-box,
|
||||
body.ai-disabled .memo-summary,
|
||||
body.ai-disabled .tag-ai,
|
||||
body.ai-disabled .ai-insight-icon,
|
||||
body.ai-disabled .ai-badge,
|
||||
body.ai-disabled .ai-loading-overlay {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.ai-disabled [data-source="ai"],
|
||||
body.ai-disabled .drawer-section:has(h3:contains("태그별 탐색")) {
|
||||
/* Note: :contains and :has are tricky, but we can target by class or JS filtering */
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/* 슬래시 명령(/) 팝업 스타일 */
|
||||
|
||||
.slash-popup {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
min-width: 180px;
|
||||
max-width: 240px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
animation: slashPopupIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* 스크롤바 커스텀 */
|
||||
.slash-popup::-webkit-scrollbar { width: 4px; }
|
||||
.slash-popup::-webkit-scrollbar-track { background: transparent; }
|
||||
.slash-popup::-webkit-scrollbar-thumb {
|
||||
background: rgba(56, 189, 248, 0.3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.slash-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, transform 0.1s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.slash-item:hover,
|
||||
.slash-item.selected {
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
}
|
||||
|
||||
.slash-item.selected {
|
||||
background: rgba(56, 189, 248, 0.18);
|
||||
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.25) inset;
|
||||
}
|
||||
|
||||
.slash-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent, #38bdf8);
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.slash-label {
|
||||
font-size: 0.82rem;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-weight: 500;
|
||||
font-family: var(--font, 'Inter', sans-serif);
|
||||
}
|
||||
|
||||
@keyframes slashPopupIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/* --- Visualization & Navigation (v3.0+) --- */
|
||||
|
||||
/* Sidebar Sections for Viz */
|
||||
.sidebar-section {
|
||||
margin-top: 10px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.calendar-content {
|
||||
max-height: 1000px;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.calendar-content.collapsed {
|
||||
max-height: 0 !important;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Calendar Widget */
|
||||
.calendar-widget {
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
margin: 5px 0 15px 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.calendar-nav {
|
||||
display: flex;
|
||||
justify-content: space-between; align-items: center;
|
||||
margin-bottom: 10px; padding: 0 5px;
|
||||
}
|
||||
|
||||
.calendar-nav span { font-weight: 600; color: var(--accent); }
|
||||
.calendar-nav button {
|
||||
background: none; border: none; color: var(--muted); cursor: pointer;
|
||||
font-size: 1rem; padding: 2px 5px; border-radius: 4px;
|
||||
}
|
||||
.calendar-nav button:hover { color: white; background: rgba(255, 255, 255, 0.1); }
|
||||
|
||||
.calendar-grid {
|
||||
display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; text-align: center;
|
||||
}
|
||||
|
||||
.calendar-day-label { color: var(--muted); font-weight: 800; font-size: 0.7rem; margin-bottom: 5px; }
|
||||
|
||||
.calendar-day {
|
||||
position: relative; padding: 6px 0; border-radius: 6px; cursor: pointer;
|
||||
transition: background 0.2s; color: var(--text-dim);
|
||||
}
|
||||
.calendar-day:hover { background: rgba(255, 255, 255, 0.1); color: white; }
|
||||
.calendar-day.today { color: var(--accent) !important; font-weight: 800; background: rgba(56, 189, 248, 0.15); }
|
||||
.calendar-day.selected { background: var(--accent) !important; color: white !important; box-shadow: 0 0 10px var(--accent); }
|
||||
.calendar-day.other-month { opacity: 0.2; }
|
||||
|
||||
/* Activity Dots */
|
||||
.activity-dot {
|
||||
position: absolute; bottom: 3px; left: 50%; transform: translateX(-50%);
|
||||
width: 4px; height: 4px; background: var(--ai-accent); border-radius: 50%;
|
||||
box-shadow: 0 0 5px var(--ai-accent);
|
||||
}
|
||||
|
||||
/* Floating Knowledge Explorer (v3.9 Compact) */
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 100px;
|
||||
left: calc(var(--sidebar-width) + 30px);
|
||||
width: 320px; /* 크기 축소 */
|
||||
max-height: 500px; /* 높이 제한 */
|
||||
z-index: 1100;
|
||||
transform: scale(0.9) translateY(20px);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease, visibility 0.3s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.6);
|
||||
border-radius: 16px; /* 더 날렵하게 */
|
||||
background: rgba(15, 23, 42, 0.97) !important;
|
||||
backdrop-filter: blur(40px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drawer.active {
|
||||
transform: scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* 드래그 중인 상태 */
|
||||
.drawer.dragging {
|
||||
transition: none; /* 드래그 중에는 애니메이션 끔 */
|
||||
opacity: 0.8;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
padding: 12px 18px; /* 여백 축소 */
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: grab;
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.drawer-header h3 {
|
||||
font-size: 0.9rem; /* 0.95 -> 0.9 */
|
||||
font-weight: 800;
|
||||
color: var(--accent);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 15px 18px; /* 여백 축소 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.1) transparent;
|
||||
}
|
||||
|
||||
.drawer .close-btn {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 1rem;
|
||||
width: 26px; /* 크기 축소 */
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.drawer .close-btn:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Colored Explorer Chips (Enhanced) */
|
||||
.explorer-section {
|
||||
margin-bottom: 25px; /* 여백 축소 */
|
||||
}
|
||||
|
||||
.explorer-section h3 {
|
||||
font-size: 0.65rem; /* 폰트 축소 */
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.explorer-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px; /* 간격 축소 */
|
||||
}
|
||||
|
||||
.explorer-chip {
|
||||
padding: 5px 12px; /* 여백 축소 */
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem; /* 0.8 -> 0.75 */
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--text-dim);
|
||||
user-select: none;
|
||||
}
|
||||
.explorer-chip:hover { background: rgba(255, 255, 255, 0.1); transform: translateY(-1px); }
|
||||
|
||||
.explorer-chip.tag-user { border-color: rgba(56, 189, 248, 0.3); color: #bae6fd; }
|
||||
.explorer-chip.tag-user:hover, .explorer-chip.tag-user.active { background: rgba(56, 189, 248, 0.15); border-color: var(--accent); color: white; }
|
||||
.explorer-chip.tag-ai { border-color: rgba(168, 85, 247, 0.3); color: #e9d5ff; }
|
||||
.explorer-chip.tag-ai:hover, .explorer-chip.tag-ai.active { background: rgba(168, 85, 247, 0.15); border-color: #a855f7; color: white; }
|
||||
@@ -0,0 +1,107 @@
|
||||
/* Main Content Area */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Top Navigation Bar */
|
||||
.topbar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 12px;
|
||||
padding: 0.8rem 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: white;
|
||||
width: 100%;
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
/* Glass Panels & Modals Base */
|
||||
.glass-panel {
|
||||
background: var(--card); backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255,255,255,0.1); border-radius: 16px;
|
||||
padding: 1.2rem; box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(5px);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal.active { display: flex; }
|
||||
|
||||
.modal-content {
|
||||
width: 90%;
|
||||
max-width: 800px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* UI Utility Components */
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid rgba(255,255,255,0.1);
|
||||
border-top: 4px solid var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
|
||||
.primary-btn {
|
||||
background: var(--accent);
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
padding: 0.5rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.primary-btn:hover { background: var(--accent-hover); }
|
||||
|
||||
.action-btn {
|
||||
background: rgba(0,0,0,0.4);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
transition: background 0.2s;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.action-btn:hover { background: rgba(184, 59, 94, 0.8); }
|
||||
.ai-btn:hover { background: var(--ai-accent); color: white; }
|
||||
@@ -0,0 +1,171 @@
|
||||
/* 🧠 뇌사료 | Premium Login Styles (v4.1) */
|
||||
|
||||
:root {
|
||||
--bg: #0b0f1a;
|
||||
--card: rgba(22, 30, 46, 0.6);
|
||||
--text: #f8fafc;
|
||||
--muted: #94a3b8;
|
||||
--accent: #38bdf8;
|
||||
--accent-hover: #0ea5e9;
|
||||
--ai-accent: #a855f7;
|
||||
--font: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; font-family: var(--font); }
|
||||
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 30%, rgba(56, 189, 248, 0.1), transparent 40%),
|
||||
radial-gradient(circle at 80% 70%, rgba(168, 85, 247, 0.1), transparent 40%);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
perspective: 1000px;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--card);
|
||||
backdrop-filter: blur(25px);
|
||||
-webkit-backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 32px;
|
||||
padding: 60px 45px;
|
||||
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.6);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
animation: cardEntrance 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes cardEntrance {
|
||||
from { opacity: 0; transform: translateY(40px) scale(0.95); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.login-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 100%; height: 6px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--ai-accent));
|
||||
}
|
||||
|
||||
.logo-area {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 12px;
|
||||
background: linear-gradient(135deg, #38bdf8, #a855f7);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Form Styling */
|
||||
.input-group {
|
||||
margin-bottom: 24px;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 16px 20px;
|
||||
color: white;
|
||||
outline: none;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.input-wrapper input:focus {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 20px rgba(56, 189, 248, 0.1);
|
||||
}
|
||||
|
||||
/* Button & Actions */
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, var(--accent), #0ea5e9);
|
||||
color: #0f172a;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 15px;
|
||||
box-shadow: 0 10px 25px -5px rgba(56, 189, 248, 0.4);
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-3px);
|
||||
background: linear-gradient(135deg, #7dd3fc, var(--accent-hover));
|
||||
box-shadow: 0 15px 30px -5px rgba(56, 189, 248, 0.6);
|
||||
}
|
||||
|
||||
.login-btn:active {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: #f87171;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 20px;
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
display: none;
|
||||
animation: shake 0.4s;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
.login-card-footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
font-size: 0.8rem;
|
||||
color: rgba(148, 163, 184, 0.5);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/* --- Mobile Optimization (v2.0+) --- */
|
||||
@media (max-width: 768px) {
|
||||
body { overflow-x: hidden; }
|
||||
|
||||
/* Sidebar as Drawer on Mobile */
|
||||
.sidebar {
|
||||
position: fixed; left: 0; top: 0; height: 100vh; z-index: 1000;
|
||||
transform: translateX(-100%); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
width: 280px; box-shadow: 20px 0 50px rgba(0,0,0,0.5);
|
||||
}
|
||||
.sidebar.mobile-open { transform: translateX(0); }
|
||||
.sidebar.collapsed { width: 280px; }
|
||||
.sidebar.collapsed .text { display: inline !important; }
|
||||
|
||||
/* Mobile Content Layout */
|
||||
.content { padding: 1rem; width: 100%; }
|
||||
.topbar { margin-bottom: 1.5rem; flex-direction: row; align-items: center; justify-content: flex-start; gap: 0; }
|
||||
.search-bar { flex: 1; order: 2; }
|
||||
#mobileMenuBtn { display: block !important; order: 1; }
|
||||
|
||||
.sidebar-toggle { font-size: 1.5rem; padding: 10px; }
|
||||
|
||||
/* Composer Adjustments */
|
||||
.meta-inputs { flex-direction: column; align-items: stretch !important; gap: 8px !important; }
|
||||
.meta-field { width: 100% !important; height: 44px !important; font-size: 1rem !important; }
|
||||
#composer input[type="text"] { height: 44px; font-size: 1.1rem; }
|
||||
.editor-resize-wrapper { height: 300px; }
|
||||
|
||||
/* Card Layout for Mobile */
|
||||
.masonry-grid { columns: 1; column-gap: 0; }
|
||||
.memo-card { max-height: none; padding-bottom: 50px; }
|
||||
.memo-card::after { height: 40px; }
|
||||
|
||||
.memo-actions { opacity: 1 !important; bottom: 8px; right: 8px; }
|
||||
.action-btn { padding: 8px 12px; font-size: 1rem; min-width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
.modal-content { width: 95%; max-height: 90vh; border-radius: 12px; }
|
||||
.tag-badge, .group-badge { padding: 4px 10px; font-size: 0.85rem; }
|
||||
|
||||
body { padding-bottom: env(safe-area-inset-bottom); }
|
||||
|
||||
/* Knowledge Drawer Mobile Adjustments */
|
||||
.drawer {
|
||||
left: 10px; right: 10px; width: auto;
|
||||
transform: translateY(110%);
|
||||
bottom: 10px; top: auto; height: 70vh;
|
||||
}
|
||||
.drawer.active { transform: translateY(0); }
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
background: var(--sidebar);
|
||||
backdrop-filter: blur(10px);
|
||||
border-right: 1px solid rgba(255,255,255,0.05);
|
||||
padding: 2rem 1.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), padding 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden; /* 전체 스크롤을 막고 내부 스크롤 활성화 */
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
margin: 0 -0.5rem; /* 스크롤바 공간 확보를 위해 패딩 살짝 조정 */
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for Sidebar */
|
||||
.sidebar-content::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(56, 189, 248, 0.3);
|
||||
}
|
||||
|
||||
.sidebar-header { margin-bottom: 2.5rem; white-space: nowrap; overflow: hidden; }
|
||||
|
||||
.logo {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #38bdf8, #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
background: none; border: none; color: var(--muted); font-size: 1.2rem; cursor: pointer;
|
||||
padding: 5px; border-radius: 4px; transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover { background: rgba(255,255,255,0.05); color: var(--text); }
|
||||
|
||||
/* Navigation List */
|
||||
.nav { list-style: none; display: flex; flex-direction: column; gap: 0; }
|
||||
|
||||
.nav li {
|
||||
padding: 0.25rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
|
||||
.nav li:hover { background: rgba(255,255,255,0.05); color: var(--text); }
|
||||
.nav li.active { background: rgba(56, 189, 248, 0.15); color: var(--accent); }
|
||||
|
||||
/* Navigation Dividers */
|
||||
.nav li:not(.sub-item):not(:first-child) {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
margin-top: 0.2rem;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
color: rgba(255,255,255,0.25);
|
||||
margin: 0.8rem 0 0.2rem 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05rem;
|
||||
list-style: none;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.sub-item { padding-left: 2.2rem !important; border-top: none !important; margin-top: 0 !important; }
|
||||
|
||||
/* Sidebar Footer */
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.sidebar-footer .action-btn { flex: 1; padding: 10px !important; }
|
||||
|
||||
#settingsBtn {
|
||||
flex: 0 0 45px !important;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 !important;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Collapsed State (Desktop) */
|
||||
.sidebar.collapsed { width: var(--sidebar-collapsed-width); padding: 2rem 0.5rem; }
|
||||
.sidebar.collapsed .text { display: none !important; }
|
||||
.sidebar.collapsed .logo { width: auto; margin-right: 0; }
|
||||
.sidebar.collapsed .nav li { padding: 0.8rem 0; justify-content: center; background: none !important; border: none !important; }
|
||||
.sidebar.collapsed .nav li.active { color: var(--accent); }
|
||||
.sidebar.collapsed .nav li .icon { font-size: 1.2rem; margin: 0; }
|
||||
.sidebar.collapsed .section-title { padding: 0.5rem 0; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); height: 1px; overflow: hidden; }
|
||||
.sidebar.collapsed .sub-item { padding-left: 0 !important; }
|
||||
|
||||
/* Hide redundant sections in collapsed state */
|
||||
.sidebar.collapsed .sidebar-section:has(#calendarHeader),
|
||||
.sidebar.collapsed .sidebar-section:has(#heatmapContainer),
|
||||
.sidebar.collapsed #calendarHeader,
|
||||
.sidebar.collapsed #calendarContainer,
|
||||
.sidebar.collapsed #heatmapContainer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Remove boxes from buttons in collapsed state */
|
||||
.sidebar.collapsed .action-btn {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
justify-content: center !important;
|
||||
padding: 10px 0 !important;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-footer { padding: 10px 0; justify-content: center; display: flex; flex-direction: column; align-items: center; border-top: 1px solid rgba(255,255,255,0.05);}
|
||||
.sidebar.collapsed #logoutBtn { padding: 8px !important; justify-content: center; }
|
||||
.sidebar.collapsed #settingsBtn { background: none !important; border: none !important; }
|
||||
|
||||
/* Tooltips for Collapsed Sidebar */
|
||||
.sidebar.collapsed [data-tooltip] { position: relative; }
|
||||
.sidebar.collapsed [data-tooltip]::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
left: 80px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1000;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.sidebar.collapsed [data-tooltip]:hover::after {
|
||||
opacity: 1;
|
||||
left: 70px;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
html { font-size: 15px; }
|
||||
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--sidebar: rgba(30, 41, 59, 0.7);
|
||||
--card: rgba(30, 41, 59, 0.85);
|
||||
--text: #f8fafc;
|
||||
--muted: #94a3b8;
|
||||
--accent: #38bdf8;
|
||||
--accent-hover: #0ea5e9;
|
||||
--ai-accent: #8b5cf6;
|
||||
--encrypted-border: #00f3ff;
|
||||
--sidebar-width: 260px;
|
||||
--sidebar-collapsed-width: 70px;
|
||||
--font: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
background-image: radial-gradient(circle at 15% 50%, rgba(56, 189, 248, 0.05), transparent 25%),
|
||||
radial-gradient(circle at 85% 30%, rgba(139, 92, 246, 0.05), transparent 25%);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 211 KiB |
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 앱의 전역 상태 및 데이터 관리 엔진 (State Management & Core Services)
|
||||
*/
|
||||
import { API } from './api.js';
|
||||
import { UI } from './ui.js';
|
||||
import { CalendarManager } from './components/CalendarManager.js';
|
||||
import { HeatmapManager } from './components/HeatmapManager.js';
|
||||
|
||||
export const AppService = {
|
||||
state: {
|
||||
memosCache: [],
|
||||
currentFilterGroup: 'all',
|
||||
currentFilterDate: null,
|
||||
currentSearchQuery: '',
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
hasMore: true,
|
||||
isLoading: false
|
||||
},
|
||||
|
||||
/**
|
||||
* 필터 상태 초기화 및 데이터 첫 페이지 로딩
|
||||
*/
|
||||
async refreshData(onUpdateSidebar) {
|
||||
this.state.offset = 0;
|
||||
this.state.memosCache = [];
|
||||
this.state.hasMore = true;
|
||||
this.state.isLoading = false;
|
||||
|
||||
// 히트맵 데이터 새로고침
|
||||
if (HeatmapManager && HeatmapManager.refresh) {
|
||||
HeatmapManager.refresh();
|
||||
}
|
||||
|
||||
await this.loadMore(onUpdateSidebar, false);
|
||||
},
|
||||
|
||||
/**
|
||||
* 다음 페이지 데이터를 가져와 병합
|
||||
*/
|
||||
async loadMore(onUpdateSidebar, isAppend = true) {
|
||||
if (this.state.isLoading || !this.state.hasMore) return;
|
||||
|
||||
this.state.isLoading = true;
|
||||
// UI.showLoading(true)는 호출부에서 관리하거나 여기서 직접 호출 가능
|
||||
|
||||
try {
|
||||
const filters = {
|
||||
group: this.state.currentFilterGroup,
|
||||
date: this.state.currentFilterDate,
|
||||
query: this.state.currentSearchQuery,
|
||||
offset: this.state.offset,
|
||||
limit: this.state.limit
|
||||
};
|
||||
|
||||
const newMemos = await API.fetchMemos(filters);
|
||||
|
||||
if (newMemos.length < this.state.limit) {
|
||||
this.state.hasMore = false;
|
||||
}
|
||||
|
||||
if (isAppend) {
|
||||
this.state.memosCache = [...this.state.memosCache, ...newMemos];
|
||||
} else {
|
||||
this.state.memosCache = newMemos;
|
||||
}
|
||||
window.allMemosCache = this.state.memosCache;
|
||||
|
||||
this.state.offset += newMemos.length;
|
||||
|
||||
// 캘린더 점 표시는 첫 로드 시에면 하면 부족할 수 있으므로,
|
||||
// 필요 시 전체 데이터를 새로 고침하는 별도 API가 필요할 수 있음.
|
||||
// 여기서는 현재 캐시된 데이터 기반으로 업데이트.
|
||||
CalendarManager.updateMemoDates(this.state.memosCache);
|
||||
|
||||
if (onUpdateSidebar) {
|
||||
onUpdateSidebar(this.state.memosCache, this.state.currentFilterGroup);
|
||||
}
|
||||
|
||||
UI.setHasMore(this.state.hasMore);
|
||||
UI.renderMemos(newMemos, {}, window.memoEventHandlers, isAppend);
|
||||
|
||||
} catch (err) {
|
||||
console.error('[AppService] loadMore failed:', err);
|
||||
} finally {
|
||||
this.state.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 필터 상태를 변경하고 데이터 초기화 후 다시 로딩
|
||||
*/
|
||||
async setFilter({ group, date, query }, onUpdateSidebar) {
|
||||
let changed = false;
|
||||
if (group !== undefined && this.state.currentFilterGroup !== group) {
|
||||
this.state.currentFilterGroup = group;
|
||||
changed = true;
|
||||
}
|
||||
if (date !== undefined && this.state.currentFilterDate !== date) {
|
||||
this.state.currentFilterDate = date;
|
||||
changed = true;
|
||||
}
|
||||
if (query !== undefined && this.state.currentSearchQuery !== query) {
|
||||
this.state.currentSearchQuery = query;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await this.refreshData(onUpdateSidebar);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 백엔드 API와의 통신을 관리하는 모듈
|
||||
*/
|
||||
|
||||
export const API = {
|
||||
async request(url, options = {}) {
|
||||
const res = await fetch(url, options);
|
||||
if (res.status === 401) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Request failed: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
async fetchMemos(filters = {}) {
|
||||
const { limit = 20, offset = 0, group = 'all', query = '' } = filters;
|
||||
const params = new URLSearchParams({ limit, offset, group, query });
|
||||
return await this.request(`/api/memos?${params.toString()}`);
|
||||
},
|
||||
async fetchHeatmapData(days = 365) {
|
||||
return await this.request(`/api/stats/heatmap?days=${days}`);
|
||||
},
|
||||
|
||||
async saveMemo(payload, id = null) {
|
||||
const url = id ? `/api/memos/${id}` : '/api/memos';
|
||||
return await this.request(url, {
|
||||
method: id ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
|
||||
async decryptMemo(id, password) {
|
||||
return await this.request(`/api/memos/${id}/decrypt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
},
|
||||
|
||||
async deleteMemo(id) {
|
||||
return await this.request(`/api/memos/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async triggerAI(id) {
|
||||
return await this.request(`/api/memos/${id}/analyze`, { method: 'POST' });
|
||||
},
|
||||
|
||||
async fetchAssets() {
|
||||
return await this.request('/api/assets');
|
||||
},
|
||||
|
||||
async uploadFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return await this.request('/api/upload', { method: 'POST', body: formData });
|
||||
},
|
||||
async deleteAttachment(filename) {
|
||||
return await this.request(`/api/attachments/${filename}`, { method: 'DELETE' });
|
||||
},
|
||||
// 설정 관련
|
||||
fetchSettings: async () => {
|
||||
const res = await fetch('/api/settings');
|
||||
return await res.json();
|
||||
},
|
||||
saveSettings: async (data) => {
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await res.json();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 첨부파일 영역 및 파일 칩 UI 컴포넌트
|
||||
*/
|
||||
import { escapeHTML } from '../utils.js';
|
||||
|
||||
/**
|
||||
* 파일 확장자에 따른 아이콘 반환
|
||||
*/
|
||||
export function getFileIcon(mime) {
|
||||
if (!mime) return '📎';
|
||||
mime = mime.toLowerCase();
|
||||
if (mime.includes('image')) return '🖼️';
|
||||
if (mime.includes('pdf')) return '📕';
|
||||
if (mime.includes('word') || mime.includes('text')) return '📄';
|
||||
if (mime.includes('zip') || mime.includes('compressed')) return '📦';
|
||||
return '📎';
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 영역 HTML 생성
|
||||
*/
|
||||
export function renderAttachmentBox(attachments) {
|
||||
if (!attachments || attachments.length === 0) return '';
|
||||
|
||||
let html = '<div class="memo-attachments">';
|
||||
attachments.forEach(a => {
|
||||
const icon = getFileIcon(a.file_type || '');
|
||||
html += `
|
||||
<a href="javascript:void(0)"
|
||||
class="file-chip"
|
||||
title="${escapeHTML(a.original_name)}"
|
||||
onclick="event.stopPropagation(); window.downloadFile('${a.filename}', '${escapeHTML(a.original_name)}')">
|
||||
<span class="icon">${icon}</span>
|
||||
<span class="name">${escapeHTML(a.original_name)}</span>
|
||||
</a>`;
|
||||
});
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
|
||||
/**
|
||||
* 사이드바 미니 캘린더 관리 모듈
|
||||
*/
|
||||
export const CalendarManager = {
|
||||
currentDate: new Date(),
|
||||
selectedDate: null,
|
||||
onDateSelect: null,
|
||||
memoDates: new Set(), // 메모가 있는 날짜들 (YYYY-MM-DD 형식)
|
||||
container: null,
|
||||
isCollapsed: false,
|
||||
|
||||
init(containerId, onDateSelect) {
|
||||
this.container = document.getElementById(containerId);
|
||||
this.onDateSelect = onDateSelect;
|
||||
|
||||
// 브라우저 저장소에서 접힘 상태 복구
|
||||
this.isCollapsed = localStorage.getItem('calendar_collapsed') === 'true';
|
||||
|
||||
this.bindEvents(); // 이벤트 먼저 바인딩
|
||||
this.updateCollapseUI();
|
||||
this.render();
|
||||
},
|
||||
|
||||
updateMemoDates(memos) {
|
||||
this.memoDates.clear();
|
||||
memos.forEach(memo => {
|
||||
if (memo.created_at) {
|
||||
const dateStr = memo.created_at.split('T')[0];
|
||||
this.memoDates.add(dateStr);
|
||||
}
|
||||
});
|
||||
this.render();
|
||||
},
|
||||
|
||||
bindEvents() {
|
||||
const header = document.getElementById('calendarHeader');
|
||||
if (header) {
|
||||
console.log('[Calendar] Binding events to header:', header);
|
||||
|
||||
const handleToggle = (e) => {
|
||||
console.log('[Calendar] Header clicked!', e.target);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 시각적 피드백: 클릭 시 잠시 배경색 변경
|
||||
const originalBg = header.style.background;
|
||||
header.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
setTimeout(() => { header.style.background = originalBg; }, 100);
|
||||
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
localStorage.setItem('calendar_collapsed', this.isCollapsed);
|
||||
this.updateCollapseUI();
|
||||
};
|
||||
|
||||
header.addEventListener('click', handleToggle, { capture: true });
|
||||
// 모바일 터치 대응을 위해 mousedown도 추가 (일부 브라우저 클릭 지연 방지)
|
||||
header.addEventListener('mousedown', (e) => console.log('[Calendar] Mousedown detected'), { capture: true });
|
||||
} else {
|
||||
console.error('[Calendar] Failed to find calendarHeader element!');
|
||||
}
|
||||
},
|
||||
|
||||
updateCollapseUI() {
|
||||
const content = document.getElementById('calendarContainer');
|
||||
const icon = document.getElementById('calendarToggleIcon');
|
||||
|
||||
if (content) {
|
||||
if (this.isCollapsed) {
|
||||
content.classList.add('collapsed');
|
||||
if (icon) icon.innerText = '▼';
|
||||
} else {
|
||||
content.classList.remove('collapsed');
|
||||
if (icon) icon.innerText = '▲';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
if (!this.container) return;
|
||||
|
||||
const year = this.currentDate.getFullYear();
|
||||
const month = this.currentDate.getMonth();
|
||||
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const prevDaysInMonth = new Date(year, month, 0).getDate();
|
||||
|
||||
const monthNames = I18nManager.t('calendar_months');
|
||||
const dayLabels = I18nManager.t('calendar_days');
|
||||
|
||||
// 문화권에 맞는 날짜 포맷팅 (예: "April 2026" vs "2026년 4월")
|
||||
const monthYearHeader = I18nManager.t('date_month_year')
|
||||
.replace('{year}', year)
|
||||
.replace('{month}', monthNames[month]);
|
||||
|
||||
let html = `
|
||||
<div class="calendar-widget glass-panel">
|
||||
<div class="calendar-nav">
|
||||
<button id="prevMonth"><</button>
|
||||
<span>${monthYearHeader}</span>
|
||||
<button id="nextMonth">></button>
|
||||
</div>
|
||||
<div class="calendar-grid">
|
||||
${dayLabels.map(day => `<div class="calendar-day-label">${day}</div>`).join('')}
|
||||
`;
|
||||
|
||||
// 이전 달 날짜들
|
||||
for (let i = firstDay - 1; i >= 0; i--) {
|
||||
html += `<div class="calendar-day other-month">${prevDaysInMonth - i}</div>`;
|
||||
}
|
||||
|
||||
// 현재 달 날짜들
|
||||
const today = new Date();
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const isToday = today.getFullYear() === year && today.getMonth() === month && today.getDate() === day;
|
||||
const isSelected = this.selectedDate === dateStr;
|
||||
const hasMemo = this.memoDates.has(dateStr);
|
||||
|
||||
html += `
|
||||
<div class="calendar-day ${isToday ? 'today' : ''} ${isSelected ? 'selected' : ''}" data-date="${dateStr}">
|
||||
${day}
|
||||
${hasMemo ? '<span class="activity-dot"></span>' : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `</div></div>`;
|
||||
this.container.innerHTML = html;
|
||||
|
||||
// 이벤트 바인딩
|
||||
this.container.querySelector('#prevMonth').onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
|
||||
this.render();
|
||||
};
|
||||
this.container.querySelector('#nextMonth').onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
|
||||
this.render();
|
||||
};
|
||||
|
||||
this.container.querySelectorAll('.calendar-day[data-date]').forEach(el => {
|
||||
el.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const date = el.dataset.date;
|
||||
if (this.selectedDate === date) {
|
||||
this.selectedDate = null; // 선택 해제
|
||||
} else {
|
||||
this.selectedDate = date;
|
||||
}
|
||||
this.render();
|
||||
if (this.onDateSelect) this.onDateSelect(this.selectedDate);
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 메모 작성 및 수정기 (Composer) 관리 모듈
|
||||
*/
|
||||
import { API } from '../api.js';
|
||||
import { EditorManager } from '../editor.js';
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
import { Constants } from '../utils/Constants.js';
|
||||
|
||||
export const ComposerManager = {
|
||||
DOM: {},
|
||||
|
||||
init(onSaveSuccess) {
|
||||
// 타이밍 이슈 방지를 위해 DOM 요소 지연 할당
|
||||
this.DOM = {
|
||||
trigger: document.getElementById('composerTrigger'),
|
||||
composer: document.getElementById('composer'),
|
||||
title: document.getElementById('memoTitle'),
|
||||
group: document.getElementById('memoGroup'),
|
||||
tags: document.getElementById('memoTags'),
|
||||
id: document.getElementById('editingMemoId'),
|
||||
encryptionToggle: document.getElementById('encryptionToggle'),
|
||||
password: document.getElementById('memoPassword'),
|
||||
foldBtn: document.getElementById('foldBtn'),
|
||||
discardBtn: document.getElementById('discardBtn')
|
||||
};
|
||||
|
||||
if (!this.DOM.composer || !this.DOM.trigger) return;
|
||||
|
||||
// 1. 이벤트 바인딩
|
||||
this.DOM.trigger.onclick = () => this.openEmpty();
|
||||
this.DOM.foldBtn.onclick = () => this.close();
|
||||
|
||||
this.DOM.discardBtn.onclick = async () => {
|
||||
if (confirm(I18nManager.t('msg_confirm_discard'))) {
|
||||
await EditorManager.cleanupSessionFiles();
|
||||
this.clear();
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
this.DOM.composer.onsubmit = (e) => {
|
||||
e.preventDefault();
|
||||
this.handleSave(onSaveSuccess);
|
||||
};
|
||||
|
||||
this.DOM.encryptionToggle.onclick = () => this.toggleEncryption();
|
||||
|
||||
// 단축키 힌트 토글 바인딩
|
||||
const shortcutToggle = document.getElementById('shortcutToggle');
|
||||
const shortcutDetails = document.getElementById('shortcutDetails');
|
||||
if (shortcutToggle && shortcutDetails) {
|
||||
shortcutToggle.onclick = () => {
|
||||
const isVisible = shortcutDetails.style.display !== 'none';
|
||||
shortcutDetails.style.display = isVisible ? 'none' : 'flex';
|
||||
const label = I18nManager.t('shortcuts_label');
|
||||
shortcutToggle.textContent = isVisible ? label : `${label} ▲`;
|
||||
};
|
||||
}
|
||||
|
||||
// --- 자동 임시저장 (Auto-Draft) ---
|
||||
this.draftTimer = setInterval(() => this.saveDraft(), 3000);
|
||||
this.checkDraftRestore();
|
||||
},
|
||||
|
||||
openEmpty() {
|
||||
this.clear();
|
||||
this.DOM.composer.style.display = 'block';
|
||||
this.DOM.trigger.style.display = 'none';
|
||||
this.DOM.title.focus();
|
||||
},
|
||||
|
||||
openForEdit(memo) {
|
||||
if (!memo) return;
|
||||
this.clear();
|
||||
this.DOM.id.value = memo.id;
|
||||
this.DOM.title.value = memo.title || '';
|
||||
this.DOM.group.value = memo.group_name || Constants.GROUPS.DEFAULT;
|
||||
this.DOM.tags.value = (memo.tags || []).filter(t => t.source === 'user').map(t => t.name).join(', ');
|
||||
|
||||
EditorManager.setMarkdown(memo.content || '');
|
||||
EditorManager.setAttachedFiles(memo.attachments || []);
|
||||
|
||||
if (memo.was_encrypted || memo.is_encrypted) {
|
||||
this.setLocked(true, memo.tempPassword || '');
|
||||
}
|
||||
|
||||
this.DOM.composer.style.display = 'block';
|
||||
this.DOM.trigger.style.display = 'none';
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
async handleSave(callback) {
|
||||
const data = {
|
||||
title: this.DOM.title.value.trim(),
|
||||
content: EditorManager.getMarkdown(),
|
||||
group_name: this.DOM.group.value.trim() || Constants.GROUPS.DEFAULT,
|
||||
tags: this.DOM.tags.value.split(',').map(t => t.trim()).filter(t => t),
|
||||
is_encrypted: this.DOM.encryptionToggle.dataset.locked === 'true',
|
||||
password: this.DOM.password.value.trim(),
|
||||
attachment_filenames: EditorManager.getAttachedFilenames()
|
||||
};
|
||||
|
||||
if (!data.title && !data.content) { this.close(); return; }
|
||||
if (data.is_encrypted && !data.password) { alert(I18nManager.t('msg_alert_password_required')); return; }
|
||||
|
||||
try {
|
||||
await API.saveMemo(data, this.DOM.id.value);
|
||||
EditorManager.sessionFiles.clear();
|
||||
this.clearDraft();
|
||||
if (callback) await callback();
|
||||
this.clear();
|
||||
this.close();
|
||||
} catch (err) { alert(err.message); }
|
||||
},
|
||||
|
||||
close() {
|
||||
this.DOM.composer.style.display = 'none';
|
||||
this.DOM.trigger.style.display = 'block';
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.DOM.id.value = '';
|
||||
this.DOM.title.value = '';
|
||||
this.DOM.group.value = Constants.GROUPS.DEFAULT;
|
||||
this.DOM.tags.value = '';
|
||||
EditorManager.setMarkdown('');
|
||||
EditorManager.setAttachedFiles([]);
|
||||
this.setLocked(false);
|
||||
},
|
||||
|
||||
toggleEncryption() {
|
||||
const isLocked = this.DOM.encryptionToggle.dataset.locked === 'true';
|
||||
this.setLocked(!isLocked);
|
||||
},
|
||||
|
||||
setLocked(locked, password = null) {
|
||||
this.DOM.encryptionToggle.dataset.locked = locked;
|
||||
this.DOM.encryptionToggle.innerText = locked ? '🔒' : '🔓';
|
||||
this.DOM.password.style.display = locked ? 'block' : 'none';
|
||||
|
||||
// 비밀번호가 명시적으로 전달된 경우에만 업데이트 (해제 시 기존 비번 유지)
|
||||
if (password !== null) {
|
||||
this.DOM.password.value = password;
|
||||
}
|
||||
|
||||
if (locked && !this.DOM.password.value) {
|
||||
this.DOM.password.focus();
|
||||
}
|
||||
},
|
||||
|
||||
// === 자동 임시저장 (Auto-Draft) ===
|
||||
|
||||
/**
|
||||
* 현재 에디터 내용을 localStorage에 자동 저장
|
||||
*/
|
||||
saveDraft() {
|
||||
// 컴포저가 닫혀있으면 저장하지 않음
|
||||
if (this.DOM.composer.style.display !== 'block') return;
|
||||
|
||||
const title = this.DOM.title.value;
|
||||
const content = EditorManager.getMarkdown();
|
||||
|
||||
// 내용이 비어있으면 저장하지 않음
|
||||
if (!title && !content) return;
|
||||
|
||||
const draft = {
|
||||
title: title,
|
||||
content: content,
|
||||
group: this.DOM.group.value,
|
||||
tags: this.DOM.tags.value,
|
||||
editingId: this.DOM.id.value,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
localStorage.setItem('memo_draft', JSON.stringify(draft));
|
||||
},
|
||||
|
||||
/**
|
||||
* 페이지 로드 시 임시저장된 내용이 있으면 복원 확인
|
||||
*/
|
||||
checkDraftRestore() {
|
||||
const raw = localStorage.getItem('memo_draft');
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const draft = JSON.parse(raw);
|
||||
|
||||
// 24시간 이상 된 임시저장은 자동 삭제
|
||||
if (Date.now() - draft.timestamp > 86400000) {
|
||||
this.clearDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
// 내용이 실제로 있는 경우에만 복원 확인
|
||||
if (!draft.title && !draft.content) {
|
||||
this.clearDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
const titlePreview = draft.title || I18nManager.t('label_untitled');
|
||||
const confirmMsg = I18nManager.t('msg_draft_restore_confirm')
|
||||
.replace('{title}', titlePreview);
|
||||
|
||||
if (confirm(confirmMsg)) {
|
||||
this.openEmpty();
|
||||
this.DOM.title.value = draft.title || '';
|
||||
this.DOM.group.value = draft.group || Constants.GROUPS.DEFAULT;
|
||||
this.DOM.tags.value = draft.tags || '';
|
||||
if (draft.editingId) this.DOM.id.value = draft.editingId;
|
||||
EditorManager.setMarkdown(draft.content || '');
|
||||
} else {
|
||||
this.clearDraft();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Draft] Failed to parse draft, deleting:', e);
|
||||
this.clearDraft();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 임시저장 데이터 삭제
|
||||
*/
|
||||
clearDraft() {
|
||||
localStorage.removeItem('memo_draft');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 지식 탐색 서랍(Drawer) 관리 모듈
|
||||
*/
|
||||
import { escapeHTML } from '../utils.js';
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
import { Constants } from '../utils/Constants.js';
|
||||
|
||||
export const DrawerManager = {
|
||||
DOM: {},
|
||||
|
||||
init() {
|
||||
this.DOM.drawer = document.getElementById('knowledgeDrawer');
|
||||
this.DOM.drawerContent = document.getElementById('drawerContent');
|
||||
const header = this.DOM.drawer?.querySelector('.drawer-header');
|
||||
|
||||
if (!this.DOM.drawer || !header) return;
|
||||
|
||||
// 닫기 버튼 이벤트
|
||||
const closeBtn = document.getElementById('closeDrawerBtn');
|
||||
if (closeBtn) {
|
||||
closeBtn.onclick = () => this.close();
|
||||
}
|
||||
|
||||
// --- 드래그 앤 드롭 로직 구현 ---
|
||||
let isDragging = false;
|
||||
let offset = { x: 0, y: 0 };
|
||||
|
||||
header.addEventListener('mousedown', (e) => {
|
||||
if (e.target.closest('.close-btn')) return; // 닫기 버튼 클릭 시 드래그 방지
|
||||
|
||||
isDragging = true;
|
||||
this.DOM.drawer.classList.add('dragging');
|
||||
|
||||
// 마우스 클릭 위치와 요소 좌상단 사이의 거리 계산
|
||||
const rect = this.DOM.drawer.getBoundingClientRect();
|
||||
offset.x = e.clientX - rect.left;
|
||||
offset.y = e.clientY - rect.top;
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// 새로운 위치 계산
|
||||
let left = e.clientX - offset.x;
|
||||
let top = e.clientY - offset.y;
|
||||
|
||||
// 화면 경계 이탈 방지
|
||||
const winW = window.innerWidth;
|
||||
const winH = window.innerHeight;
|
||||
const cardW = this.DOM.drawer.offsetWidth;
|
||||
const cardH = this.DOM.drawer.offsetHeight;
|
||||
|
||||
left = Math.max(0, Math.min(left, winW - cardW));
|
||||
top = Math.max(0, Math.min(top, winH - cardH));
|
||||
|
||||
this.DOM.drawer.style.left = `${left}px`;
|
||||
this.DOM.drawer.style.top = `${top}px`;
|
||||
this.DOM.drawer.style.bottom = 'auto'; // bottom 제거
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
isDragging = false;
|
||||
this.DOM.drawer?.classList.remove('dragging');
|
||||
});
|
||||
},
|
||||
|
||||
open(memos = [], activeFilter, onFilterCallback) {
|
||||
if (!this.DOM.drawer || !this.DOM.drawerContent) return;
|
||||
|
||||
// 0. 데이터 유효성 검사
|
||||
if (!memos || memos.length === 0) {
|
||||
this.DOM.drawerContent.innerHTML = `<p style="color:var(--muted); text-align:center; padding:20px;">${I18nManager.t('label_no_results')}</p>`;
|
||||
this.DOM.drawer.classList.add('active');
|
||||
return;
|
||||
}
|
||||
// 1. 그룹 및 태그 카운트 계산
|
||||
const groupAllKey = 'all';
|
||||
const groupCounts = { [groupAllKey]: memos.length };
|
||||
const tagCounts = {};
|
||||
const tagsSourceMap = new Map();
|
||||
|
||||
memos.forEach(m => {
|
||||
const g = m.group_name || Constants.GROUPS.DEFAULT;
|
||||
groupCounts[g] = (groupCounts[g] || 0) + 1;
|
||||
|
||||
if (m.tags) {
|
||||
m.tags.forEach(t => {
|
||||
tagCounts[t.name] = (tagCounts[t.name] || 0) + 1;
|
||||
const current = tagsSourceMap.get(t.name);
|
||||
if (!current || t.source === 'user') tagsSourceMap.set(t.name, t.source);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sortedGroups = Object.keys(groupCounts).filter(g => g !== groupAllKey).sort();
|
||||
const sortedTags = Object.keys(tagCounts).sort().map(tn => ({
|
||||
name: tn,
|
||||
source: tagsSourceMap.get(tn),
|
||||
count: tagCounts[tn]
|
||||
}));
|
||||
|
||||
// 2. HTML 렌더링
|
||||
let html = `
|
||||
<div class="explorer-section">
|
||||
<h3>${I18nManager.t('drawer_title_groups')}</h3>
|
||||
<div class="explorer-grid">
|
||||
<div class="explorer-chip ${activeFilter === 'all' ? 'active' : ''}" data-filter="all">
|
||||
💡 ${I18nManager.t('nav_all')} <span class="chip-count">${groupCounts[groupAllKey]}</span>
|
||||
</div>
|
||||
${sortedGroups.map(g => `
|
||||
<div class="explorer-chip ${activeFilter === g ? 'active' : ''}" data-filter="${escapeHTML(g)}">
|
||||
📁 ${escapeHTML(g)} <span class="chip-count">${groupCounts[g]}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="explorer-section" style="margin-top:20px;">
|
||||
<h3>${I18nManager.t('drawer_title_tags')}</h3>
|
||||
<div class="explorer-grid">
|
||||
${sortedTags.map(t => `
|
||||
<div class="explorer-chip ${t.source === 'ai' ? 'tag-ai' : 'tag-user'} ${activeFilter === `tag:${t.source}:${t.name}` ? 'active' : ''}"
|
||||
data-filter="tag:${t.source}:${escapeHTML(t.name)}">
|
||||
${t.source === 'ai' ? '🪄' : '🏷️'} ${escapeHTML(t.name)} <span class="chip-count">${t.count}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.DOM.drawerContent.innerHTML = html;
|
||||
this.DOM.drawer.classList.add('active');
|
||||
|
||||
// 3. 이벤트 바인딩
|
||||
this.DOM.drawerContent.querySelectorAll('.explorer-chip').forEach(chip => {
|
||||
chip.onclick = () => {
|
||||
const filter = chip.dataset.filter;
|
||||
onFilterCallback(filter);
|
||||
// 선택 시 서랍을 닫을지 유지할지는 UX 선택 (일단 닫음)
|
||||
// this.close();
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
close() {
|
||||
if (this.DOM.drawer) {
|
||||
this.DOM.drawer.classList.remove('active');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
|
||||
/**
|
||||
* 지식 성장 히트맵(Heatmap) 관리 모듈
|
||||
* 최근 지정된 기간(기본 365일) 동안의 메모 작성 활동량을 시각화합니다.
|
||||
*/
|
||||
export const HeatmapManager = {
|
||||
container: null,
|
||||
data: [], // [{date: 'YYYY-MM-DD', count: N}, ...]
|
||||
currentRange: 365, // 기본 365일
|
||||
|
||||
init(containerId) {
|
||||
this.container = document.getElementById(containerId);
|
||||
if (!this.container) {
|
||||
console.warn('[Heatmap] Container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 로컬스토리지에서 이전에 선택한 범위 복구
|
||||
const savedRange = localStorage.getItem('heatmap_range');
|
||||
if (savedRange) {
|
||||
this.currentRange = parseInt(savedRange, 10);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 데이터를 서버에서 가져와 렌더링합니다.
|
||||
*/
|
||||
async refresh() {
|
||||
try {
|
||||
const { API } = await import('../api.js');
|
||||
this.data = await API.fetchHeatmapData(this.currentRange);
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('[Heatmap] Failed to fetch stats:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 히트맵 그리드를 생성합니다.
|
||||
*/
|
||||
render() {
|
||||
if (!this.container) return;
|
||||
|
||||
const dataMap = new Map(this.data.map(d => [d.date, d.count]));
|
||||
|
||||
// 날짜 계산
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - (this.currentRange - 1));
|
||||
|
||||
// 요일 맞추기 (일요일 시작 기준)
|
||||
const dayOfWeek = startDate.getDay();
|
||||
const adjustedStartDate = new Date(startDate);
|
||||
adjustedStartDate.setDate(startDate.getDate() - dayOfWeek);
|
||||
|
||||
const rangeLabel = I18nManager.t(`heatmap_ranges.${this.currentRange}`) || I18nManager.t('label_select_range');
|
||||
|
||||
const heatmapTitle = I18nManager.t('label_heatmap_title');
|
||||
const rangeOptions = I18nManager.t('heatmap_ranges');
|
||||
const labelLess = I18nManager.t('label_less');
|
||||
const labelMore = I18nManager.t('label_more');
|
||||
|
||||
let html = `
|
||||
<div class="heatmap-wrapper glass-panel">
|
||||
<div class="heatmap-header">
|
||||
<span class="heatmap-title">${heatmapTitle}</span>
|
||||
<select id="heatmapRangeSelect" class="heatmap-select">
|
||||
${Object.entries(rangeOptions).map(([val, label]) => `
|
||||
<option value="${val}" ${this.currentRange.toString() === val ? 'selected' : ''}>${label}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="heatmap-grid" id="heatmapGrid">
|
||||
`;
|
||||
|
||||
const formatDate = (date) => {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
// 전체 표시 일수 (범위 + 요일 보정)
|
||||
const totalCells = this.currentRange + dayOfWeek + (6 - today.getDay());
|
||||
|
||||
for (let i = 0; i < totalCells; i++) {
|
||||
const currentDate = new Date(adjustedStartDate);
|
||||
currentDate.setDate(adjustedStartDate.getDate() + i);
|
||||
|
||||
const dateStr = formatDate(currentDate);
|
||||
const count = dataMap.get(dateStr) || 0;
|
||||
const level = this.calculateLevel(count);
|
||||
|
||||
const isOutOfRange = currentDate < startDate || currentDate > today;
|
||||
|
||||
const tooltip = I18nManager.t('tooltip_heatmap_stat')
|
||||
.replace('{date}', dateStr)
|
||||
.replace('{count}', count);
|
||||
|
||||
html += `
|
||||
<div class="heatmap-cell ${isOutOfRange ? 'out' : `lvl-${level}`}"
|
||||
data-date="${dateStr}"
|
||||
data-count="${count}"
|
||||
title="${tooltip}">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
<div class="heatmap-legend">
|
||||
<span>${labelLess}</span>
|
||||
<div class="heatmap-cell lvl-0"></div>
|
||||
<div class="heatmap-cell lvl-1"></div>
|
||||
<div class="heatmap-cell lvl-2"></div>
|
||||
<div class="heatmap-cell lvl-3"></div>
|
||||
<div class="heatmap-cell lvl-4"></div>
|
||||
<span>${labelMore}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.container.innerHTML = html;
|
||||
this.bindEvents();
|
||||
},
|
||||
|
||||
calculateLevel(count) {
|
||||
if (count === 0) return 0;
|
||||
if (count <= 1) return 1;
|
||||
if (count <= 3) return 2;
|
||||
if (count <= 5) return 3;
|
||||
return 4;
|
||||
},
|
||||
|
||||
bindEvents() {
|
||||
const select = this.container.querySelector('#heatmapRangeSelect');
|
||||
if (select) {
|
||||
select.onchange = (e) => {
|
||||
this.currentRange = parseInt(e.target.value, 10);
|
||||
localStorage.setItem('heatmap_range', this.currentRange);
|
||||
this.refresh();
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 메모 카드 컴포넌트
|
||||
*/
|
||||
import { escapeHTML, parseInternalLinks, fixImagePaths } from '../utils.js';
|
||||
import { renderAttachmentBox } from './AttachmentBox.js';
|
||||
import { Constants } from '../utils/Constants.js';
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
|
||||
/**
|
||||
* 단일 메모 카드의 HTML 생성을 전담합니다.
|
||||
*/
|
||||
export function createMemoCardHtml(memo, isDone) {
|
||||
const cardClass = `memo-card ${isDone ? 'done' : ''} ${memo.is_encrypted ? 'encrypted' : ''} glass-panel`;
|
||||
const borderStyle = memo.color ? `style="border-left: 5px solid ${memo.color}"` : '';
|
||||
|
||||
let summaryHtml = '';
|
||||
if (memo.summary) {
|
||||
// 암호화된 메모가 잠긴 상태라면 AI 요약도 숨김 (정보 유출 방지)
|
||||
const isLocked = memo.is_encrypted && (!memo.content || memo.content.includes('encrypted-block') || typeof memo.is_encrypted === 'number');
|
||||
// 참고: app.js에서 해독 성공 시 memo.is_encrypted를 false로 바꿨으므로, is_encrypted가 true면 잠긴 상태임
|
||||
if (!memo.is_encrypted) {
|
||||
summaryHtml = `<div class="memo-summary"><strong>${I18nManager.t('label_ai_summary')}:</strong> ${escapeHTML(memo.summary)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
const titleHtml = memo.title ? `<h3 class="memo-title">${escapeHTML(memo.title)}</h3>` : '';
|
||||
|
||||
let htmlContent = '';
|
||||
if (!isDone) {
|
||||
if (memo.is_encrypted) {
|
||||
htmlContent = `
|
||||
<div class="encrypted-block" style="display:flex; align-items:center; gap:10px; padding:8px 12px; background:rgba(255,255,255,0.03); border-radius:8px; border:1px solid rgba(255,255,255,0.05);">
|
||||
<span style="font-size:1rem;">🔒</span>
|
||||
<span style="font-size:0.85rem; color:var(--muted); flex:1;">${I18nManager.t('msg_encrypted_locked')}</span>
|
||||
<button class="action-btn unlock-btn" data-id="${memo.id}" style="font-size:0.75rem; padding:4px 10px; background:var(--ai-accent);">${I18nManager.t('btn_unlock')}</button>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// marked로 파싱한 후 DOMPurify로 살균하여 XSS 방지
|
||||
htmlContent = DOMPurify.sanitize(marked.parse(memo.content || ''));
|
||||
htmlContent = parseInternalLinks(htmlContent);
|
||||
htmlContent = fixImagePaths(htmlContent);
|
||||
}
|
||||
}
|
||||
const contentHtml = `<div class="memo-content">${htmlContent}</div>`;
|
||||
|
||||
let metaHtml = '<div class="memo-meta">';
|
||||
if (!isDone && memo.group_name && memo.group_name !== Constants.GROUPS.DEFAULT) {
|
||||
const groupName = (Object.values(Constants.GROUPS).includes(memo.group_name))
|
||||
? I18nManager.t(`groups.${memo.group_name}`)
|
||||
: memo.group_name;
|
||||
metaHtml += `<span class="group-badge">📁 ${escapeHTML(groupName)}</span>`;
|
||||
}
|
||||
if (memo.tags && memo.tags.length > 0) {
|
||||
memo.tags.forEach(t => {
|
||||
// 암호화된 메모가 잠긴 상태일 때 AI 태그만 선택적으로 숨김
|
||||
if (memo.is_encrypted && t.source === 'ai') return;
|
||||
|
||||
const typeClass = t.source === 'ai' ? 'tag-ai' : 'tag-user';
|
||||
metaHtml += `<span class="tag-badge ${typeClass}">${t.source === 'ai' ? '🪄 ' : '#'}${escapeHTML(t.name)}</span>`;
|
||||
});
|
||||
}
|
||||
metaHtml += '</div>';
|
||||
|
||||
let linksHtml = '';
|
||||
if (!isDone && memo.backlinks && memo.backlinks.length > 0) {
|
||||
linksHtml = `<div class="memo-backlinks">🔗 ${I18nManager.t('label_mentioned')}: ` +
|
||||
memo.backlinks.map(l => `<span class="link-item" data-id="${l.id}">#${escapeHTML(l.title || l.id.toString())}</span>`).join(', ') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// 암호화된 메모인 경우 해독 전까지 첨부파일 목록 숨김
|
||||
const attachmentsHtml = !memo.is_encrypted ? renderAttachmentBox(memo.attachments) : '';
|
||||
|
||||
// 암호화된 메모가 잠긴 상태라면 하단 액션 버튼(수정, 삭제, AI 등)을 아예 보여주지 않음 (보안 및 UI 겹침 방지)
|
||||
const isLocked = memo.is_encrypted && (!htmlContent || htmlContent.includes('encrypted-block'));
|
||||
const actionsHtml = isLocked ? '' : `
|
||||
<div class="memo-actions">
|
||||
<button class="action-btn toggle-pin" data-id="${memo.id}" title="${I18nManager.t('title_pin')}">${memo.is_pinned ? '⭐' : '☆'}</button>
|
||||
<button class="action-btn toggle-status" data-id="${memo.id}" title="${isDone ? I18nManager.t('title_undo') : I18nManager.t('title_done')}">${isDone ? '↩️' : '✅'}</button>
|
||||
${!isDone ? `<button class="action-btn ai-btn" data-id="${memo.id}" title="${I18nManager.t('title_ai')}">🪄</button>` : ''}
|
||||
<button class="action-btn edit-btn" data-id="${memo.id}" title="${I18nManager.t('title_edit')}">✏️</button>
|
||||
<button class="action-btn delete-btn" data-id="${memo.id}" title="${I18nManager.t('title_delete')}">🗑️</button>
|
||||
</div>
|
||||
`;
|
||||
const idBadge = `<div style="position:absolute; top:10px; right:12px; color:rgba(255,255,255,0.15); font-size:10px; font-weight:900;">#${memo.id}</div>`;
|
||||
|
||||
return {
|
||||
className: cardClass,
|
||||
style: borderStyle,
|
||||
innerHtml: idBadge + summaryHtml + titleHtml + metaHtml + contentHtml + linksHtml + attachmentsHtml + actionsHtml
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 모달 창(메모 상세, 파일 라이브러리 등) 생성을 관리하는 모듈
|
||||
*/
|
||||
import { API } from '../api.js';
|
||||
import { escapeHTML } from '../utils.js';
|
||||
import { renderAttachmentBox } from './AttachmentBox.js';
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
import { Constants } from '../utils/Constants.js';
|
||||
|
||||
export const ModalManager = {
|
||||
// 타이밍 이슈 방지를 위해 lazy getter 패턴 적용
|
||||
getDOM() {
|
||||
return {
|
||||
modal: document.getElementById('memoModal'),
|
||||
modalContent: document.getElementById('modalContent'),
|
||||
loadingOverlay: document.getElementById('loadingOverlay'),
|
||||
explorerModal: document.getElementById('explorerModal'),
|
||||
explorerContent: document.getElementById('explorerContent')
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 전체 첨부파일 라이브러리(Asset Library) 모달 열기
|
||||
*/
|
||||
async openAssetLibrary(openMemoDetailsCallback) {
|
||||
const dom = this.getDOM();
|
||||
if (!dom.loadingOverlay) return;
|
||||
|
||||
dom.loadingOverlay.style.display = 'flex';
|
||||
try {
|
||||
const assets = await API.fetchAssets();
|
||||
let html = `
|
||||
<div style="padding:20px; position:relative;">
|
||||
<button class="close-modal-btn">×</button>
|
||||
<h2 style="margin-bottom:20px;">${I18nManager.t('label_asset_management')}</h2>
|
||||
<p style="font-size:0.8rem; color:var(--muted); margin-bottom:20px;">${I18nManager.t('label_asset_hint')}</p>
|
||||
<div style="display:grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap:15px;">
|
||||
${assets.length > 0 ? assets.map(a => `
|
||||
<div class="asset-card" data-memo-id="${a.memo_id}" data-url="/api/download/${a.filename}" style="background:rgba(255,255,255,0.05); padding:10px; border-radius:8px; cursor:pointer;">
|
||||
${['png','jpg','jpeg','gif','webp','svg'].includes(a.file_type?.toLowerCase())
|
||||
? `<img src="/api/download/${a.filename}" style="width:100%; height:120px; object-fit:cover; border-radius:4px; margin-bottom:8px;">`
|
||||
: `<div style="width:100%; height:120px; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.2); border-radius:4px; margin-bottom:8px; font-size:2rem;">📎</div>`
|
||||
}
|
||||
<div style="font-size:0.8rem; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${escapeHTML(a.original_name)}</div>
|
||||
<div style="font-size:0.7rem; color:var(--muted);">${a.memo_title ? `${I18nManager.t('label_memo_ref')}${escapeHTML(a.memo_title)}` : I18nManager.t('label_no_memo_ref')}</div>
|
||||
</div>
|
||||
`).join('') : `<div style="grid-column:1/-1; text-align:center; padding:40px; color:var(--muted);">${I18nManager.t('label_no_assets')}</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
dom.modalContent.innerHTML = html;
|
||||
dom.modal.classList.add('active');
|
||||
|
||||
// 닫기 버튼 이벤트
|
||||
dom.modalContent.querySelector('.close-modal-btn').onclick = () => {
|
||||
dom.modal.classList.remove('active');
|
||||
};
|
||||
|
||||
dom.modalContent.querySelectorAll('.asset-card').forEach(card => {
|
||||
card.onclick = (e) => {
|
||||
const url = card.dataset.url;
|
||||
const filename = url.split('/').pop();
|
||||
const originalName = card.querySelector('div').innerText;
|
||||
const memoId = card.dataset.memoId;
|
||||
|
||||
if (e.altKey) {
|
||||
e.stopPropagation();
|
||||
window.downloadFile(filename, originalName);
|
||||
} else if (memoId && memoId !== 'null') {
|
||||
dom.modal.classList.remove('active');
|
||||
openMemoDetailsCallback(memoId, window.allMemosCache);
|
||||
} else {
|
||||
window.downloadFile(filename, originalName);
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch (err) { alert(err.message); }
|
||||
finally { dom.loadingOverlay.style.display = 'none'; }
|
||||
},
|
||||
|
||||
/**
|
||||
* 지식 탐색기(Knowledge Explorer) 모달 열기
|
||||
*/
|
||||
openKnowledgeExplorer(memos, activeFilter, onFilterCallback) {
|
||||
const dom = this.getDOM();
|
||||
// 1. 그룹 및 태그 카운트 계산
|
||||
const groupAllKey = 'all';
|
||||
const groupCounts = { [groupAllKey]: memos.length };
|
||||
const tagCounts = {};
|
||||
const tagsSourceMap = new Map(); // 태그명 -> 소스 매핑
|
||||
|
||||
memos.forEach(m => {
|
||||
const g = m.group_name || Constants.GROUPS.DEFAULT;
|
||||
groupCounts[g] = (groupCounts[g] || 0) + 1;
|
||||
|
||||
if (m.tags) {
|
||||
m.tags.forEach(t => {
|
||||
tagCounts[t.name] = (tagCounts[t.name] || 0) + 1;
|
||||
const current = tagsSourceMap.get(t.name);
|
||||
if (!current || t.source === 'user') tagsSourceMap.set(t.name, t.source);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sortedGroups = Object.keys(groupCounts)
|
||||
.filter(g => g !== groupAllKey)
|
||||
.sort((a,b) => a === Constants.GROUPS.DEFAULT ? -1 : b === Constants.GROUPS.DEFAULT ? 1 : a.localeCompare(b));
|
||||
|
||||
const sortedTags = Object.keys(tagCounts).sort().map(tn => ({
|
||||
name: tn,
|
||||
source: tagsSourceMap.get(tn),
|
||||
count: tagCounts[tn]
|
||||
}));
|
||||
|
||||
let html = `
|
||||
<div class="explorer-section">
|
||||
<h3 style="margin-bottom:15px; color:var(--accent);">${I18nManager.t('label_group_explorer')}</h3>
|
||||
<div class="explorer-grid">
|
||||
<div class="explorer-chip ${activeFilter === 'all' ? 'active' : ''}" data-filter="all">
|
||||
💡 ${I18nManager.t('nav_all')} <span class="chip-count">${groupCounts[groupAllKey]}</span>
|
||||
</div>
|
||||
${sortedGroups.map(g => `
|
||||
<div class="explorer-chip ${activeFilter === g ? 'active' : ''}" data-filter="${escapeHTML(g)}">
|
||||
📁 ${escapeHTML(g)} <span class="chip-count">${groupCounts[g]}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="explorer-section" style="margin-top:30px;">
|
||||
<h3 style="margin-bottom:15px; color:var(--ai-accent);">${I18nManager.t('label_tag_explorer')}</h3>
|
||||
<div class="explorer-grid">
|
||||
${sortedTags.map(t => `
|
||||
<div class="explorer-chip tag-chip ${activeFilter === `tag:${t.source}:${t.name}` ? 'active' : ''}"
|
||||
data-filter="tag:${t.source}:${escapeHTML(t.name)}">
|
||||
${t.source === 'ai' ? '🪄' : '🏷️'} ${escapeHTML(t.name)} <span class="chip-count">${t.count}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
dom.explorerContent.innerHTML = html;
|
||||
dom.explorerModal.classList.add('active');
|
||||
|
||||
// 이벤트 바인딩
|
||||
const closeBtn = dom.explorerModal.querySelector('.close-explorer-btn');
|
||||
closeBtn.onclick = () => dom.explorerModal.classList.remove('active');
|
||||
|
||||
dom.explorerContent.querySelectorAll('.explorer-chip').forEach(chip => {
|
||||
chip.onclick = () => {
|
||||
const filter = chip.dataset.filter;
|
||||
onFilterCallback(filter);
|
||||
dom.explorerModal.classList.remove('active');
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 개별 메모 상세 모달 열기
|
||||
*/
|
||||
openMemoModal(id, memos) {
|
||||
const dom = this.getDOM();
|
||||
const memo = memos.find(m => m.id == id);
|
||||
if (!memo) return;
|
||||
|
||||
import('../utils.js').then(({ parseInternalLinks, fixImagePaths }) => {
|
||||
// 마크다운 파싱 후 살균 처리 (marked, DOMPurify는 global 사용)
|
||||
let html = DOMPurify.sanitize(marked.parse(memo.content));
|
||||
html = parseInternalLinks(html);
|
||||
html = fixImagePaths(html);
|
||||
|
||||
const lastUpdatedTime = new Date(memo.updated_at).toLocaleString();
|
||||
|
||||
dom.modalContent.innerHTML = `
|
||||
<button class="close-modal-btn">×</button>
|
||||
${memo.title ? `<h2 style="margin-bottom:10px;">${escapeHTML(memo.title)}</h2>` : ''}
|
||||
|
||||
${memo.summary ? `
|
||||
<div class="ai-summary-box" style="margin: 15px 0 25px 0; padding: 15px; background: rgba(56, 189, 248, 0.1); border-left: 4px solid var(--accent); border-radius: 8px; position: relative; overflow: hidden;">
|
||||
<div style="font-size: 0.7rem; color: var(--accent); font-weight: 800; margin-bottom: 8px; display: flex; align-items: center; gap: 5px; letter-spacing: 0.05em;">
|
||||
<span>🪄 AI INSIGHT</span>
|
||||
</div>
|
||||
<div style="font-size: 0.95rem; line-height: 1.6; color: #e2e8f0; font-weight: 400;">${escapeHTML(memo.summary)}</div>
|
||||
</div>
|
||||
` : '<hr style="margin:15px 0; opacity:0.1">'}
|
||||
|
||||
<div class="memo-content">${html}</div>
|
||||
<div style="margin-top:20px; font-size:0.8rem; color:var(--muted)">${I18nManager.t('label_last_updated')}${lastUpdatedTime}</div>
|
||||
`;
|
||||
|
||||
// 닫기 버튼 이벤트
|
||||
const closeBtn = dom.modalContent.querySelector('.close-modal-btn');
|
||||
if (closeBtn) {
|
||||
closeBtn.onclick = () => dom.modal.classList.remove('active');
|
||||
}
|
||||
|
||||
const attachmentsHtml = renderAttachmentBox(memo.attachments);
|
||||
if (attachmentsHtml) {
|
||||
const footer = document.createElement('div');
|
||||
footer.style.cssText = 'margin-top:30px; padding-top:15px; border-top:1px solid rgba(255,255,255,0.05);';
|
||||
footer.innerHTML = attachmentsHtml;
|
||||
dom.modalContent.appendChild(footer);
|
||||
}
|
||||
|
||||
dom.modal.classList.add('active');
|
||||
dom.modalContent.querySelectorAll('.internal-link').forEach(l => {
|
||||
l.onclick = () => this.openMemoModal(l.dataset.id, memos);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 사이드바 그룹 목록 컴포넌트
|
||||
*/
|
||||
import { escapeHTML } from '../utils.js';
|
||||
import { Constants } from '../utils/Constants.js';
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
|
||||
/**
|
||||
* 그룹 목록 HTML 렌더링
|
||||
*/
|
||||
export function renderGroupList(container, groups, activeGroup, onGroupClick) {
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
groups.forEach(group => {
|
||||
const li = document.createElement('li');
|
||||
const isActive = group === activeGroup || (group === Constants.GROUPS.DEFAULT && activeGroup === 'all');
|
||||
li.className = isActive ? 'active' : '';
|
||||
|
||||
// 아이콘 선택 및 클래스 추가
|
||||
let icon = '📁';
|
||||
if (group === Constants.GROUPS.DEFAULT || group === 'all') icon = '💡';
|
||||
else if (group === Constants.GROUPS.FILES) icon = '📂';
|
||||
else if (group === Constants.GROUPS.DONE) icon = '✅';
|
||||
else if (group.startsWith('tag:')) {
|
||||
const parts = group.split(':'); // tag:source:name
|
||||
const source = parts[1];
|
||||
icon = source === 'ai' ? '🪄' : '🏷️';
|
||||
li.classList.add(source === 'ai' ? 'tag-ai' : 'tag-user');
|
||||
}
|
||||
|
||||
// 표시 이름 결정
|
||||
let label = group;
|
||||
if (group === 'all') label = I18nManager.t('groups.all');
|
||||
else if (group === Constants.GROUPS.DEFAULT) label = I18nManager.t('groups.default');
|
||||
else if (group === Constants.GROUPS.FILES) label = I18nManager.t('groups.files');
|
||||
else if (group === Constants.GROUPS.DONE) label = I18nManager.t('groups.done');
|
||||
else if (group.startsWith('tag:')) {
|
||||
const parts = group.split(':');
|
||||
label = parts[2]; // 태그 이름
|
||||
}
|
||||
|
||||
li.innerHTML = `<span class="icon">${icon}</span> <span class="text">${escapeHTML(label)}</span>`;
|
||||
li.onclick = () => onGroupClick(group);
|
||||
container.appendChild(li);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
|
||||
export const SlashCommand = {
|
||||
// 사용 가능한 명령 목록
|
||||
commands: [
|
||||
{ icon: '☑️', label: I18nManager.t('slash.task'), cmd: 'taskList' },
|
||||
{ icon: '•', label: I18nManager.t('slash.bullet'), cmd: 'bulletList' },
|
||||
{ icon: '1.', label: I18nManager.t('slash.number'), cmd: 'orderedList' },
|
||||
{ icon: '❝', label: I18nManager.t('slash.quote'), cmd: 'blockQuote' },
|
||||
{ icon: '—', label: I18nManager.t('slash.line'), cmd: 'thematicBreak' },
|
||||
{ icon: '{}', label: I18nManager.t('slash.code'), cmd: 'codeBlock' },
|
||||
{ icon: 'H1', label: I18nManager.t('slash.h1'), cmd: 'heading', payload: { level: 1 } },
|
||||
{ icon: 'H2', label: I18nManager.t('slash.h2'), cmd: 'heading', payload: { level: 2 } },
|
||||
{ icon: 'H3', label: I18nManager.t('slash.h3'), cmd: 'heading', payload: { level: 3 } },
|
||||
{ icon: '🪄', label: I18nManager.t('slash.ai_summary'), cmd: 'ai-summary', isAI: true },
|
||||
{ icon: '🏷️', label: I18nManager.t('slash.ai_tags'), cmd: 'ai-tags', isAI: true },
|
||||
],
|
||||
|
||||
popupEl: null,
|
||||
selectedIndex: 0,
|
||||
isOpen: false,
|
||||
editorRef: null,
|
||||
editorElRef: null,
|
||||
filterText: '', // '/' 이후 입력된 필터 텍스트
|
||||
filteredCommands: [], // 필터링된 명령 목록
|
||||
|
||||
/**
|
||||
* 초기화: 팝업 DOM 생성 및 이벤트 바인딩
|
||||
*/
|
||||
init(editor, editorEl) {
|
||||
this.editorRef = editor;
|
||||
this.editorElRef = editorEl;
|
||||
console.log('[SlashCmd] init 호출됨, editor:', !!editor, 'editorEl:', !!editorEl);
|
||||
|
||||
// 팝업 컨테이너 생성
|
||||
this.popupEl = document.createElement('div');
|
||||
this.popupEl.id = 'slashCommandPopup';
|
||||
this.popupEl.className = 'slash-popup';
|
||||
this.popupEl.style.display = 'none';
|
||||
document.body.appendChild(this.popupEl);
|
||||
|
||||
// 에디터 keydown 이벤트 (팝업 열린 상태에서 네비게이션 가로채기)
|
||||
editorEl.addEventListener('keydown', (e) => {
|
||||
if (!this.isOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.navigate(1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.navigate(-1);
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.executeSelected();
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.hide();
|
||||
break;
|
||||
case 'Backspace':
|
||||
// 필터 텍스트 삭제, '/'까지 지우면 팝업 닫기
|
||||
if (this.filterText.length > 0) {
|
||||
this.filterText = this.filterText.slice(0, -1);
|
||||
this.updateFilter();
|
||||
} else {
|
||||
// '/' 자체를 지우는 경우 → 팝업 닫기
|
||||
this.hide();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// 일반 문자 입력 시 필터링 적용
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
this.filterText += e.key;
|
||||
this.updateFilter();
|
||||
// 필터 결과가 없으면 팝업 닫기
|
||||
if (this.filteredCommands.length === 0) {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, true); // capture 단계
|
||||
|
||||
// 에디터 keyup 이벤트 ('/' 입력 감지)
|
||||
editorEl.addEventListener('keyup', (e) => {
|
||||
console.log('[SlashCmd] keyup:', e.key, 'isOpen:', this.isOpen);
|
||||
if (this.isOpen) return; // 이미 열려있으면 무시
|
||||
|
||||
if (e.key === '/') {
|
||||
console.log('[SlashCmd] / 감지, WYSIWYG:', this.editorRef.isWysiwygMode());
|
||||
// WYSIWYG 모드에서만 동작
|
||||
if (!this.editorRef.isWysiwygMode()) return;
|
||||
|
||||
// 줄 시작이거나 공백 뒤에서만 팝업 활성화
|
||||
const shouldActivate = this._shouldActivate();
|
||||
console.log('[SlashCmd] shouldActivate:', shouldActivate);
|
||||
if (shouldActivate) {
|
||||
const rect = this._getCursorRect();
|
||||
console.log('[SlashCmd] cursorRect:', rect);
|
||||
if (rect) {
|
||||
this.filterText = '';
|
||||
this.filteredCommands = [...this.commands];
|
||||
this.show(rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
// 에디터 외부 클릭 시 팝업 닫기
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
if (this.isOpen && !this.popupEl.contains(e.target)) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// 에디터 스크롤/리사이즈 시 팝업 닫기
|
||||
editorEl.addEventListener('scroll', () => { if (this.isOpen) this.hide(); }, true);
|
||||
window.addEventListener('resize', () => { if (this.isOpen) this.hide(); });
|
||||
},
|
||||
|
||||
/**
|
||||
* '/' 입력이 유효한 위치인지 판별
|
||||
* (줄 시작 또는 공백/빈 줄 뒤)
|
||||
*/
|
||||
_shouldActivate() {
|
||||
const sel = window.getSelection();
|
||||
console.log('[SlashCmd] _shouldActivate - sel:', !!sel, 'rangeCount:', sel?.rangeCount);
|
||||
if (!sel || sel.rangeCount === 0) return false;
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const node = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
console.log('[SlashCmd] node type:', node.nodeType, 'offset:', offset, 'nodeName:', node.nodeName);
|
||||
|
||||
// Case 1: 텍스트 노드 내부에 커서가 있는 경우
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const textBefore = node.textContent.substring(0, offset);
|
||||
console.log('[SlashCmd] TEXT_NODE textBefore:', JSON.stringify(textBefore));
|
||||
if (textBefore === '/' || textBefore.endsWith(' /') || textBefore.endsWith('\n/')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: 요소 노드 내부에 커서가 있는 경우 (WYSIWYG contenteditable)
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
// offset 위치의 바로 앞 자식 노드 확인
|
||||
const childBefore = node.childNodes[offset - 1];
|
||||
console.log('[SlashCmd] ELEMENT_NODE childBefore:', childBefore?.nodeType, 'text:', JSON.stringify(childBefore?.textContent));
|
||||
|
||||
if (childBefore) {
|
||||
const text = childBefore.textContent || '';
|
||||
if (text === '/' || text.endsWith(' /') || text.endsWith('\n/')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 요소의 전체 텍스트에서 마지막 문자 확인 (fallback)
|
||||
const fullText = node.textContent || '';
|
||||
console.log('[SlashCmd] ELEMENT_NODE fullText:', JSON.stringify(fullText));
|
||||
if (fullText === '/' || fullText.endsWith(' /') || fullText.endsWith('\n/')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[SlashCmd] shouldActivate → false (조건 불충족)');
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* 현재 커서의 화면 좌표(px) 반환
|
||||
*/
|
||||
_getCursorRect() {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return null;
|
||||
|
||||
const range = sel.getRangeAt(0).cloneRange();
|
||||
range.collapse(true);
|
||||
|
||||
// 빈 영역에서도 좌표를 얻기 위해 임시 span 삽입
|
||||
const span = document.createElement('span');
|
||||
span.textContent = '\u200b'; // zero-width space
|
||||
range.insertNode(span);
|
||||
const rect = span.getBoundingClientRect();
|
||||
const result = { top: rect.top, left: rect.left, bottom: rect.bottom };
|
||||
span.parentNode.removeChild(span);
|
||||
|
||||
// Selection 복원
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* 팝업 표시
|
||||
*/
|
||||
show(rect) {
|
||||
this.selectedIndex = 0;
|
||||
this.isOpen = true;
|
||||
this._renderItems();
|
||||
|
||||
// 팝업 위치 계산 (커서 바로 아래)
|
||||
const popupHeight = this.popupEl.offsetHeight || 280;
|
||||
const viewportH = window.innerHeight;
|
||||
|
||||
// 화면 아래 공간이 부족하면 위에 표시
|
||||
if (rect.bottom + popupHeight > viewportH) {
|
||||
this.popupEl.style.top = `${rect.top - popupHeight - 4}px`;
|
||||
} else {
|
||||
this.popupEl.style.top = `${rect.bottom + 4}px`;
|
||||
}
|
||||
this.popupEl.style.left = `${Math.max(8, rect.left)}px`;
|
||||
this.popupEl.style.display = 'block';
|
||||
},
|
||||
|
||||
/**
|
||||
* 팝업 숨기기
|
||||
*/
|
||||
hide() {
|
||||
this.isOpen = false;
|
||||
this.popupEl.style.display = 'none';
|
||||
this.filterText = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* 필터링 업데이트
|
||||
*/
|
||||
updateFilter() {
|
||||
const q = this.filterText.toLowerCase();
|
||||
const isAIDisabled = document.body.classList.contains('ai-disabled');
|
||||
|
||||
this.filteredCommands = this.commands.filter(c => {
|
||||
if (c.isAI && isAIDisabled) return false;
|
||||
return c.label.toLowerCase().includes(q) || c.cmd.toLowerCase().includes(q);
|
||||
});
|
||||
this.selectedIndex = 0;
|
||||
this._renderItems();
|
||||
},
|
||||
|
||||
/**
|
||||
* 팝업 내 항목 DOM 렌더링
|
||||
*/
|
||||
_renderItems() {
|
||||
this.popupEl.innerHTML = this.filteredCommands.map((c, i) => `
|
||||
<div class="slash-item ${i === this.selectedIndex ? 'selected' : ''}" data-index="${i}">
|
||||
<span class="slash-icon">${c.icon}</span>
|
||||
<span class="slash-label">${c.label}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// 마우스 클릭 이벤트
|
||||
this.popupEl.querySelectorAll('.slash-item').forEach(item => {
|
||||
item.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault(); // 에디터 포커스 유지
|
||||
this.selectedIndex = parseInt(item.dataset.index);
|
||||
this.executeSelected();
|
||||
});
|
||||
item.addEventListener('mouseenter', () => {
|
||||
this.selectedIndex = parseInt(item.dataset.index);
|
||||
this._highlightSelected();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 선택 항목 하이라이트 갱신
|
||||
*/
|
||||
_highlightSelected() {
|
||||
this.popupEl.querySelectorAll('.slash-item').forEach((el, i) => {
|
||||
el.classList.toggle('selected', i === this.selectedIndex);
|
||||
});
|
||||
|
||||
// 선택된 항목이 보이도록 스크롤
|
||||
const selectedEl = this.popupEl.querySelector('.slash-item.selected');
|
||||
if (selectedEl) {
|
||||
selectedEl.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* ↑↓ 네비게이션
|
||||
*/
|
||||
navigate(direction) {
|
||||
const len = this.filteredCommands.length;
|
||||
if (len === 0) return;
|
||||
this.selectedIndex = (this.selectedIndex + direction + len) % len;
|
||||
this._highlightSelected();
|
||||
},
|
||||
|
||||
/**
|
||||
* 선택된 명령 실행
|
||||
*/
|
||||
executeSelected() {
|
||||
const cmd = this.filteredCommands[this.selectedIndex];
|
||||
if (!cmd) { this.hide(); return; }
|
||||
|
||||
// 1. '/' + 필터 텍스트를 에디터에서 삭제
|
||||
this._deleteSlashAndFilter();
|
||||
|
||||
// 2. 팝업 닫기
|
||||
this.hide();
|
||||
|
||||
// 3. 에디터 포커스 유지 후 명령 실행
|
||||
this.editorRef.focus();
|
||||
|
||||
// 짧은 딜레이 후 명령 실행 (DOM 반영 대기)
|
||||
requestAnimationFrame(() => {
|
||||
if (cmd.payload) {
|
||||
this.editorRef.exec(cmd.cmd, cmd.payload);
|
||||
} else {
|
||||
this.editorRef.exec(cmd.cmd);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* '/' 문자와 필터 텍스트를 에디터 본문에서 삭제
|
||||
*/
|
||||
_deleteSlashAndFilter() {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return;
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const node = range.startContainer;
|
||||
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const offset = range.startOffset;
|
||||
const deleteLen = 1 + this.filterText.length; // '/' + filter
|
||||
const start = offset - deleteLen;
|
||||
|
||||
if (start >= 0) {
|
||||
// 텍스트 노드에서 직접 삭제
|
||||
node.textContent = node.textContent.substring(0, start) + node.textContent.substring(offset);
|
||||
|
||||
// 커서를 삭제 위치로 복원
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(node, start);
|
||||
newRange.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(newRange);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import { API } from '../api.js';
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
|
||||
export const ThemeManager = {
|
||||
/**
|
||||
* 환경 설정 및 개인화 테마 로직 초기화
|
||||
*/
|
||||
async initSettings() {
|
||||
const settingsBtn = document.getElementById('settingsBtn');
|
||||
const settingsModal = document.getElementById('settingsModal');
|
||||
const closeSettingsBtn = document.getElementById('closeSettingsBtn');
|
||||
const saveThemeBtn = document.getElementById('saveThemeBtn');
|
||||
const resetThemeBtn = document.getElementById('resetThemeBtn');
|
||||
const pickers = settingsModal.querySelectorAll('input[type="color"]');
|
||||
|
||||
// 1. 서버에서 설정 불러오기 및 적용
|
||||
try {
|
||||
const settings = await API.fetchSettings();
|
||||
await this.applyTheme(settings);
|
||||
// 만약 서버에 설정된 테마가 없다면 시스템 테마 감지 시작
|
||||
if (Object.keys(settings).length === 0) {
|
||||
this.initSystemThemeDetection();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err);
|
||||
this.initSystemThemeDetection();
|
||||
}
|
||||
|
||||
// ... 나머지 모달 제어 로직 유지 (기존 코드와 동일)
|
||||
if (settingsBtn) settingsBtn.onclick = () => settingsModal.classList.add('active');
|
||||
if (closeSettingsBtn) closeSettingsBtn.onclick = () => settingsModal.classList.remove('active');
|
||||
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target === settingsModal) settingsModal.classList.remove('active');
|
||||
});
|
||||
|
||||
pickers.forEach(picker => {
|
||||
picker.oninput = (e) => {
|
||||
const variable = e.target.dataset.var;
|
||||
const value = e.target.value;
|
||||
document.documentElement.style.setProperty(variable, value);
|
||||
};
|
||||
});
|
||||
|
||||
if (saveThemeBtn) {
|
||||
saveThemeBtn.onclick = async () => {
|
||||
const data = {};
|
||||
const mapping = {
|
||||
'set-bg': 'bg_color',
|
||||
'set-sidebar': 'sidebar_color',
|
||||
'set-card': 'card_color',
|
||||
'set-encrypted': 'encrypted_border',
|
||||
'set-ai': 'ai_accent'
|
||||
};
|
||||
|
||||
pickers.forEach(p => {
|
||||
data[mapping[p.id]] = p.value;
|
||||
});
|
||||
data['enable_ai'] = document.getElementById('set-enable-ai').checked;
|
||||
|
||||
// 언어 설정이 UI에 있다면 추가 (현재는 config.json 수동 명시 권장이나 대비책 마련)
|
||||
const langSelect = document.getElementById('set-lang');
|
||||
if (langSelect) data['lang'] = langSelect.value;
|
||||
|
||||
try {
|
||||
await API.saveSettings(data);
|
||||
await this.applyTheme(data);
|
||||
alert(I18nManager.t('msg_settings_saved'));
|
||||
settingsModal.classList.remove('active');
|
||||
} catch (err) { alert('저장 실패: ' + err.message); }
|
||||
};
|
||||
}
|
||||
|
||||
if (resetThemeBtn) {
|
||||
resetThemeBtn.onclick = () => {
|
||||
if (confirm('모든 색상을 기본값으로 되돌릴까요?')) {
|
||||
const defaults = {
|
||||
bg_color: "#0f172a",
|
||||
sidebar_color: "rgba(30, 41, 59, 0.7)",
|
||||
card_color: "rgba(30, 41, 59, 0.85)",
|
||||
encrypted_border: "#00f3ff",
|
||||
ai_accent: "#8b5cf6",
|
||||
lang: "ko"
|
||||
};
|
||||
this.applyTheme(defaults);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 테마 데이터를 실제 CSS 변수 및 UI 요소에 반영
|
||||
*/
|
||||
async applyTheme(settings) {
|
||||
const mapping = {
|
||||
'bg_color': '--bg',
|
||||
'sidebar_color': '--sidebar',
|
||||
'card_color': '--card',
|
||||
'encrypted_border': '--encrypted-border',
|
||||
'ai_accent': '--ai-accent'
|
||||
};
|
||||
|
||||
for (const [key, variable] of Object.entries(mapping)) {
|
||||
if (settings[key]) {
|
||||
document.documentElement.style.setProperty(variable, settings[key]);
|
||||
const pickerId = 'set-' + key.split('_')[0];
|
||||
const picker = document.getElementById(pickerId);
|
||||
if (picker) {
|
||||
picker.value = settings[key].startsWith('rgba') ? this.rgbaToHex(settings[key]) : settings[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. AI 활성화 상태 적용
|
||||
const enableAI = (settings.enable_ai !== false);
|
||||
document.body.classList.toggle('ai-disabled', !enableAI);
|
||||
const aiToggle = document.getElementById('set-enable-ai');
|
||||
if (aiToggle) aiToggle.checked = enableAI;
|
||||
|
||||
// 3. i18n 적용
|
||||
const lang = settings.lang || 'ko';
|
||||
await I18nManager.init(lang);
|
||||
const langSelect = document.getElementById('set-lang');
|
||||
if (langSelect) langSelect.value = lang;
|
||||
},
|
||||
|
||||
rgbaToHex(rgba) {
|
||||
const parts = rgba.match(/[\d.]+/g);
|
||||
if (!parts || parts.length < 3) return '#0f172a';
|
||||
const r = parseInt(parts[0]);
|
||||
const g = parseInt(parts[1]);
|
||||
const b = parseInt(parts[2]);
|
||||
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
|
||||
},
|
||||
|
||||
/**
|
||||
* 시스템 다크/라이트 모드 감지 및 자동 적용
|
||||
*/
|
||||
initSystemThemeDetection() {
|
||||
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleThemeChange = (e) => {
|
||||
const isDark = e.matches;
|
||||
const theme = isDark ? {
|
||||
bg_color: "#0f172a",
|
||||
sidebar_color: "rgba(30, 41, 59, 0.7)",
|
||||
card_color: "rgba(30, 41, 59, 0.85)",
|
||||
encrypted_border: "#00f3ff",
|
||||
ai_accent: "#8b5cf6",
|
||||
lang: "ko"
|
||||
} : {
|
||||
bg_color: "#f8fafc",
|
||||
sidebar_color: "rgba(241, 245, 249, 0.8)",
|
||||
card_color: "#ffffff",
|
||||
encrypted_border: "#0ea5e9",
|
||||
ai_accent: "#6366f1",
|
||||
lang: "ko"
|
||||
};
|
||||
this.applyTheme(theme);
|
||||
};
|
||||
|
||||
darkModeMediaQuery.addEventListener('change', handleThemeChange);
|
||||
handleThemeChange(darkModeMediaQuery);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* 지식 시각화 맵(Graph) 관리 모듈 (v7.5 - D3.js 기반 혁신)
|
||||
*/
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
import { Constants } from '../utils/Constants.js';
|
||||
|
||||
export const Visualizer = {
|
||||
simulation: null,
|
||||
svg: null,
|
||||
container: null,
|
||||
width: 0,
|
||||
height: 0,
|
||||
|
||||
init(containerId) {
|
||||
this.container = document.getElementById(containerId);
|
||||
if (!this.container) {
|
||||
console.error(`[Visualizer] Container #${containerId} not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 초기 크기 설정
|
||||
this.width = this.container.clientWidth;
|
||||
this.height = this.container.clientHeight;
|
||||
console.log(`[Visualizer] Init - Size: ${this.width}x${this.height}`);
|
||||
},
|
||||
|
||||
render(memos, onNodeClick) {
|
||||
console.log(`[Visualizer] Rendering ${memos.length} memos...`);
|
||||
if (!this.container) return;
|
||||
|
||||
// 모달이 열리는 중이라 크기가 0일 경우 대비 재측정
|
||||
if (this.width === 0 || this.height === 0) {
|
||||
this.width = this.container.clientWidth || 800;
|
||||
this.height = this.container.clientHeight || 600;
|
||||
console.log(`[Visualizer] Re-measured Size: ${this.width}x${this.height}`);
|
||||
}
|
||||
|
||||
// 0. 기존 내용 청소
|
||||
this.container.innerHTML = '';
|
||||
|
||||
// 1. 데이터 전처리
|
||||
const uniqueGroups = [...new Set(memos.map(m => m.group_name || Constants.GROUPS.DEFAULT))];
|
||||
const groupCenters = {};
|
||||
const radius = Math.min(this.width, this.height) * 0.35;
|
||||
|
||||
// 그룹별 성단 중심점 계산 (원형 레이아웃)
|
||||
uniqueGroups.forEach((g, i) => {
|
||||
const angle = (i / uniqueGroups.length) * Math.PI * 2;
|
||||
groupCenters[g] = {
|
||||
x: this.width / 2 + Math.cos(angle) * radius,
|
||||
y: this.height / 2 + Math.sin(angle) * radius
|
||||
};
|
||||
});
|
||||
|
||||
const nodes = memos.map(m => ({
|
||||
...m,
|
||||
id: m.id.toString(),
|
||||
group: m.group_name || Constants.GROUPS.DEFAULT,
|
||||
weight: (m.links ? m.links.length : 0) + 5
|
||||
}));
|
||||
|
||||
const links = [];
|
||||
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
||||
|
||||
// 1. 명시적 링크 (Internal Links) 처리
|
||||
memos.forEach(m => {
|
||||
if (m.links) {
|
||||
m.links.forEach(l => {
|
||||
const targetId = (l.target_id || l.id).toString();
|
||||
if (nodeMap.has(targetId)) {
|
||||
links.push({ source: m.id.toString(), target: targetId, type: 'explicit' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 공통 태그 및 그룹 기반 자동 연결 (Constellation Links)
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const nodeA = nodes[i];
|
||||
const nodeB = nodes[j];
|
||||
|
||||
// 태그 목록 추출
|
||||
const tagsA = new Set((nodeA.tags || []).map(t => t.name));
|
||||
const tagsB = new Set((nodeB.tags || []).map(t => t.name));
|
||||
|
||||
// 교집합 확인 (태그 링크)
|
||||
const commonTags = [...tagsA].filter(t => tagsB.has(t));
|
||||
if (commonTags.length > 0) {
|
||||
links.push({
|
||||
source: nodeA.id,
|
||||
target: nodeB.id,
|
||||
type: 'tag',
|
||||
strength: commonTags.length
|
||||
});
|
||||
} else if (nodeA.group === nodeB.group) {
|
||||
// 동일 그룹 내 자동 연결 (성단 형성) - 태그가 없을 때만
|
||||
links.push({
|
||||
source: nodeA.id,
|
||||
target: nodeB.id,
|
||||
type: 'group',
|
||||
strength: 0.1
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Visualizer] Data Prepared - Nodes: ${nodes.length}, Links: ${links.length}, Groups: ${uniqueGroups.length}`);
|
||||
const totalTags = nodes.reduce((acc, n) => acc + (n.tags ? n.tags.length : 0), 0);
|
||||
console.log(`[Visualizer] Total Tags in Data: ${totalTags}`);
|
||||
|
||||
// 2. SVG 생성
|
||||
this.svg = d3.select(this.container)
|
||||
.append('svg')
|
||||
.attr('width', '100%')
|
||||
.attr('height', '100%')
|
||||
.style('background', 'radial-gradient(circle at center, #1e293b 0%, #020617 100%)')
|
||||
.attr('viewBox', `0 0 ${this.width} ${this.height}`);
|
||||
|
||||
// 우주 배경 (작은 별들) 생성
|
||||
const starCount = 100;
|
||||
const stars = Array.from({ length: starCount }, () => ({
|
||||
x: Math.random() * this.width,
|
||||
y: Math.random() * this.height,
|
||||
r: Math.random() * 1.5,
|
||||
opacity: Math.random()
|
||||
}));
|
||||
|
||||
this.svg.selectAll('.star')
|
||||
.data(stars)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('class', 'star')
|
||||
.attr('cx', d => d.x)
|
||||
.attr('cy', d => d.y)
|
||||
.attr('r', d => d.r)
|
||||
.style('fill', '#fff')
|
||||
.style('opacity', d => d.opacity);
|
||||
|
||||
// 글로우 효과 필터 정의
|
||||
const defs = this.svg.append('defs');
|
||||
const filter = defs.append('filter')
|
||||
.attr('id', 'glow');
|
||||
filter.append('feGaussianBlur')
|
||||
.attr('stdDeviation', '3.5')
|
||||
.attr('result', 'coloredBlur');
|
||||
const feMerge = filter.append('feMerge');
|
||||
feMerge.append('feMergeNode').attr('in', 'coloredBlur');
|
||||
feMerge.append('feMergeNode').attr('in', 'SourceGraphic');
|
||||
|
||||
const g = this.svg.append('g').attr('class', 'main-g');
|
||||
|
||||
// 3. 줌(Zoom) 설정
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.1, 5])
|
||||
.on('zoom', (event) => g.attr('transform', event.transform));
|
||||
this.svg.call(zoom);
|
||||
|
||||
// 4. 그룹 라벨 생성 (Subtle Center Labels)
|
||||
const groupLabels = g.selectAll('.group-label')
|
||||
.data(uniqueGroups)
|
||||
.join('text')
|
||||
.attr('class', 'group-label')
|
||||
.attr('x', d => groupCenters[d].x)
|
||||
.attr('y', d => groupCenters[d].y)
|
||||
.text(d => d)
|
||||
.style('fill', 'rgba(56, 189, 248, 0.2)')
|
||||
.style('font-size', '14px')
|
||||
.style('font-weight', 'bold')
|
||||
.style('text-anchor', 'middle')
|
||||
.style('pointer-events', 'none');
|
||||
|
||||
// 5. 물리 시뮬레이션 설정 (Force Simulation)
|
||||
this.simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links).id(d => d.id).distance(100).strength(0.1))
|
||||
.force('charge', d3.forceManyBody().strength(-200)) // 서로 밀어냄
|
||||
.force('collide', d3.forceCollide().radius(d => d.weight + 20))
|
||||
.force('x', d3.forceX(d => groupCenters[d.group].x).strength(0.08)) // 그룹 중심으로 당김
|
||||
.force('y', d3.forceY(d => groupCenters[d.group].y).strength(0.08))
|
||||
.force('center', d3.forceCenter(this.width / 2, this.height / 2).strength(0.01));
|
||||
|
||||
// 6. 링크(선) 활성화
|
||||
const link = g.selectAll('.link')
|
||||
.data(links)
|
||||
.join('line')
|
||||
.attr('class', 'link')
|
||||
.style('stroke', d => {
|
||||
if (d.type === 'explicit') return '#38bdf8';
|
||||
if (d.type === 'tag') return '#8b5cf6';
|
||||
return 'rgba(56, 189, 248, 0.05)'; // group links
|
||||
})
|
||||
.style('stroke-width', d => d.type === 'explicit' ? 2 : 1)
|
||||
.style('stroke-dasharray', d => d.type === 'group' ? '2,2' : 'none')
|
||||
.style('opacity', d => d.type === 'group' ? 0.3 : 0.6);
|
||||
|
||||
// 7. 노드(점) 활성화
|
||||
const node = g.selectAll('.node')
|
||||
.data(nodes)
|
||||
.join('g')
|
||||
.attr('class', d => `node ${d.is_encrypted ? 'encrypted' : ''}`)
|
||||
.call(d3.drag()
|
||||
.on('start', dragstarted)
|
||||
.on('drag', dragged)
|
||||
.on('end', dragended))
|
||||
.on('click', (event, d) => onNodeClick && onNodeClick(d.id))
|
||||
.on('mouseover', function(event, d) {
|
||||
// 이웃 노드 및 링크 하이라이트
|
||||
const neighborIds = new Set();
|
||||
neighborIds.add(d.id);
|
||||
links.forEach(l => {
|
||||
if (l.source.id === d.id) neighborIds.add(l.target.id);
|
||||
if (l.target.id === d.id) neighborIds.add(l.source.id);
|
||||
});
|
||||
|
||||
node.style('opacity', n => neighborIds.has(n.id) ? 1 : 0.1);
|
||||
link.style('stroke', l => (l.source.id === d.id || l.target.id === d.id) ? '#38bdf8' : 'rgba(56, 189, 248, 0.05)')
|
||||
.style('stroke-opacity', l => (l.source.id === d.id || l.target.id === d.id) ? 1 : 0.2);
|
||||
})
|
||||
.on('mouseout', function() {
|
||||
node.style('opacity', 1);
|
||||
link.style('stroke', 'rgba(56, 189, 248, 0.1)')
|
||||
.style('stroke-opacity', 0.6);
|
||||
});
|
||||
|
||||
// 노드 원형 스타일
|
||||
node.append('circle')
|
||||
.attr('r', d => d.weight)
|
||||
.style('fill', d => d.is_encrypted ? '#64748b' : '#38bdf8')
|
||||
.style('filter', 'url(#glow)')
|
||||
.style('cursor', 'pointer');
|
||||
|
||||
// 노드 텍스트 라벨
|
||||
node.append('text')
|
||||
.attr('dy', d => d.weight + 15)
|
||||
.text(d => {
|
||||
const untitled = I18nManager.t('label_untitled');
|
||||
const title = d.title || untitled;
|
||||
return d.is_encrypted ? `🔒 ${title}` : title;
|
||||
})
|
||||
.style('fill', d => d.is_encrypted ? '#94a3b8' : '#cbd5e1')
|
||||
.style('font-size', '10px')
|
||||
.style('text-anchor', 'middle')
|
||||
.style('pointer-events', 'none')
|
||||
.style('text-shadow', '0 2px 4px rgba(0,0,0,0.8)');
|
||||
|
||||
// 8. 틱(Tick)마다 좌표 업데이트
|
||||
this.simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', d => d.source.x)
|
||||
.attr('y1', d => d.source.y)
|
||||
.attr('x2', d => d.target.x)
|
||||
.attr('y2', d => d.target.y);
|
||||
|
||||
node
|
||||
.attr('transform', d => `translate(${d.x}, ${d.y})`);
|
||||
});
|
||||
|
||||
// 드래그 함수
|
||||
const self = this;
|
||||
function dragstarted(event, d) {
|
||||
if (!event.active) self.simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
function dragged(event, d) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event, d) {
|
||||
if (!event.active) self.simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}
|
||||
},
|
||||
|
||||
resize() {
|
||||
if (!this.container || !this.svg) return;
|
||||
this.width = this.container.clientWidth;
|
||||
this.height = this.container.clientHeight;
|
||||
this.svg.attr('viewBox', `0 0 ${this.width} ${this.height}`);
|
||||
this.simulation.force('center', d3.forceCenter(this.width / 2, this.height / 2));
|
||||
this.simulation.alpha(0.3).restart();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import { API } from './api.js';
|
||||
import { renderAttachmentBox } from './components/AttachmentBox.js';
|
||||
import { SlashCommand } from './components/SlashCommand.js';
|
||||
import { I18nManager } from './utils/I18nManager.js';
|
||||
|
||||
export const EditorManager = {
|
||||
editor: null,
|
||||
attachedFiles: [], // 현재 에디터에 첨부된 파일들
|
||||
sessionFiles: new Set(), // 이번 세션에 새로 추가된 파일 트래킹 (취소 시 삭제용)
|
||||
|
||||
init(elSelector, onCtrlEnter) {
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
// --- 플러그인 설정 (글자 색상) ---
|
||||
const colorPlugin = (window.toastui && window.toastui.EditorPluginColorSyntax) ||
|
||||
(window.toastui && window.toastui.Editor && window.toastui.Editor.plugin && window.toastui.Editor.plugin.colorSyntax);
|
||||
|
||||
const plugins = (typeof colorPlugin === 'function') ? [colorPlugin] : [];
|
||||
|
||||
this.editor = new toastui.Editor({
|
||||
el: document.querySelector(elSelector),
|
||||
height: '100%',
|
||||
initialEditType: 'wysiwyg',
|
||||
previewStyle: isMobile ? 'tab' : 'vertical',
|
||||
theme: 'dark',
|
||||
placeholder: I18nManager.t('composer_placeholder'),
|
||||
plugins: plugins,
|
||||
toolbarItems: isMobile ? [
|
||||
['heading', 'bold', 'italic', 'strike'],
|
||||
['hr', 'quote'],
|
||||
['ul', 'ol', 'task'],
|
||||
['table', 'image', 'link'],
|
||||
['code', 'codeblock']
|
||||
] : [
|
||||
['heading', 'bold', 'italic', 'strike'],
|
||||
['hr', 'quote'],
|
||||
['ul', 'ol', 'task', 'indent', 'outdent'],
|
||||
['table', 'image', 'link'],
|
||||
['code', 'codeblock'],
|
||||
['scrollSync']
|
||||
],
|
||||
hooks: {
|
||||
addImageBlobHook: async (blob, callback) => {
|
||||
try {
|
||||
const data = await API.uploadFile(blob);
|
||||
if (data.url) {
|
||||
const filename = data.url.split('/').pop();
|
||||
callback(`/api/download/${filename}`, data.name || 'image');
|
||||
|
||||
this.attachedFiles.push({
|
||||
filename: filename,
|
||||
original_name: data.name || 'image',
|
||||
file_type: blob.type
|
||||
});
|
||||
this.sessionFiles.add(filename);
|
||||
this.refreshAttachmentUI();
|
||||
}
|
||||
} catch (err) { alert(err.message); }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- 키보드 단축키 시스템 ---
|
||||
const editorEl = document.querySelector(elSelector);
|
||||
|
||||
// Ctrl+Shift 조합 단축키 맵 (toolbar 메뉴 대체)
|
||||
const shortcutMap = {
|
||||
'x': 'taskList', // Ctrl+Shift+X : 체크박스(Task) 토글
|
||||
'u': 'bulletList', // Ctrl+Shift+U : 순서 없는 목록
|
||||
'o': 'orderedList', // Ctrl+Shift+O : 순서 있는 목록
|
||||
'q': 'blockQuote', // Ctrl+Shift+Q : 인용 블록
|
||||
'k': 'codeBlock', // Ctrl+Shift+K : 코드 블록
|
||||
'l': 'thematicBreak', // Ctrl+Shift+L : 수평선(구분선)
|
||||
']': 'indent', // Ctrl+Shift+] : 들여쓰기
|
||||
'[': 'outdent', // Ctrl+Shift+[ : 내어쓰기
|
||||
};
|
||||
|
||||
editorEl.addEventListener('keydown', (e) => {
|
||||
// 1. Ctrl+Enter → 저장
|
||||
if (onCtrlEnter && e.ctrlKey && !e.shiftKey && (e.key === 'Enter' || e.keyCode === 13)) {
|
||||
onCtrlEnter();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Ctrl+Shift+[Key] → toolbar 명령 실행
|
||||
if (e.ctrlKey && e.shiftKey) {
|
||||
const cmd = shortcutMap[e.key.toLowerCase()];
|
||||
if (cmd) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.editor.exec(cmd);
|
||||
}
|
||||
}
|
||||
}, true); // capture 단계에서 잡아서 에디터 내부 이벤트보다 먼저 처리
|
||||
|
||||
// --- 슬래시 명령(/) 팝업 초기화 ---
|
||||
SlashCommand.init(this.editor, editorEl);
|
||||
|
||||
return this.editor;
|
||||
},
|
||||
|
||||
setAttachedFiles(files) {
|
||||
console.log('[Editor] Loading attachments:', files);
|
||||
this.attachedFiles = (files || []).map(f => ({
|
||||
filename: f.filename || f.file_name,
|
||||
original_name: f.original_name || f.name || 'file',
|
||||
file_type: f.file_type || f.type || ''
|
||||
}));
|
||||
this.sessionFiles.clear(); // 기존 파일을 로드할 때는 세션 트래킹 초기화 (기존 파일은 삭제 대상 제외)
|
||||
this.refreshAttachmentUI();
|
||||
},
|
||||
|
||||
refreshAttachmentUI() {
|
||||
const container = document.getElementById('editorAttachments');
|
||||
if (!container) {
|
||||
console.warn('[Editor] #editorAttachments element not found in DOM!');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Editor] Refreshing UI with:', this.attachedFiles);
|
||||
container.innerHTML = renderAttachmentBox(this.attachedFiles);
|
||||
},
|
||||
|
||||
bindDropEvent(wrapperSelector, onDropComplete) {
|
||||
const wrapper = document.querySelector(wrapperSelector);
|
||||
wrapper.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); });
|
||||
wrapper.addEventListener('drop', async (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
// 에디터가 닫혀있다면 상위에서 열어줘야 함
|
||||
onDropComplete(true);
|
||||
|
||||
for (let file of files) {
|
||||
try {
|
||||
const data = await API.uploadFile(file);
|
||||
if (data.url) {
|
||||
const filename = data.url.split('/').pop();
|
||||
const isImg = ['png','jpg','jpeg','gif','webp','svg'].includes(data.ext?.toLowerCase());
|
||||
const name = data.name || 'file';
|
||||
|
||||
// Ensure editor is focused before inserting
|
||||
this.editor.focus();
|
||||
|
||||
if (isImg) {
|
||||
this.editor.exec('addImage', { altText: name, imageUrl: data.url });
|
||||
}
|
||||
|
||||
// 공통: 첨부 파일 목록에 추가 및 UI 갱신
|
||||
this.attachedFiles.push({
|
||||
filename: filename,
|
||||
original_name: name,
|
||||
file_type: file.type
|
||||
});
|
||||
this.sessionFiles.add(filename); // 세션 트래킹 추가
|
||||
this.refreshAttachmentUI();
|
||||
}
|
||||
} catch (err) { console.error(err); }
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getAttachedFilenames() {
|
||||
return this.attachedFiles.map(f => f.filename);
|
||||
},
|
||||
|
||||
/**
|
||||
* 취소(삭제) 시 세션 동안 추가된 파일들을 서버에서 지움
|
||||
*/
|
||||
async cleanupSessionFiles() {
|
||||
if (this.sessionFiles.size === 0) return;
|
||||
|
||||
console.log(`[Editor] Cleaning up ${this.sessionFiles.size} session files...`);
|
||||
const filesToDelete = Array.from(this.sessionFiles);
|
||||
for (const filename of filesToDelete) {
|
||||
try {
|
||||
await API.deleteAttachment(filename);
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete session file ${filename}:`, err);
|
||||
}
|
||||
}
|
||||
this.sessionFiles.clear();
|
||||
},
|
||||
|
||||
getMarkdown() { return this.editor.getMarkdown().trim(); },
|
||||
setMarkdown(md) { this.editor.setMarkdown(md); },
|
||||
focus() { this.editor.focus(); }
|
||||
};
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* UI 렌더링 및 이벤트를 관리하는 오케스트레이터 (Orchestrator)
|
||||
*/
|
||||
import { API } from './api.js';
|
||||
import { createMemoCardHtml } from './components/MemoCard.js';
|
||||
import { renderGroupList } from './components/SidebarUI.js';
|
||||
import { ThemeManager } from './components/ThemeManager.js';
|
||||
import { ModalManager } from './components/ModalManager.js';
|
||||
import { I18nManager } from './utils/I18nManager.js';
|
||||
|
||||
const DOM = {
|
||||
memoGrid: document.getElementById('memoGrid'),
|
||||
groupList: document.getElementById('groupList'),
|
||||
modal: document.getElementById('memoModal'),
|
||||
loadingOverlay: document.getElementById('loadingOverlay'),
|
||||
searchInput: document.getElementById('searchInput'),
|
||||
sidebar: document.getElementById('sidebar'),
|
||||
systemNav: document.getElementById('systemNav'),
|
||||
scrollSentinel: document.getElementById('scrollSentinel')
|
||||
};
|
||||
|
||||
export const UI = {
|
||||
/**
|
||||
* 사이드바 및 로그아웃 버튼 초기화
|
||||
*/
|
||||
initSidebarToggle() {
|
||||
const toggle = document.getElementById('sidebarToggle');
|
||||
const sidebar = DOM.sidebar;
|
||||
const overlay = document.getElementById('sidebarOverlay');
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
|
||||
if (toggle && sidebar) {
|
||||
const isCollapsed = localStorage.getItem('sidebarCollapsed') === 'true';
|
||||
if (isCollapsed) {
|
||||
sidebar.classList.add('collapsed');
|
||||
const calendar = document.getElementById('calendarContainer');
|
||||
if (calendar) calendar.style.display = 'none';
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
if (isMobile) {
|
||||
sidebar.classList.toggle('mobile-open');
|
||||
overlay.style.display = sidebar.classList.contains('mobile-open') ? 'block' : 'none';
|
||||
} else {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
const collapsed = sidebar.classList.contains('collapsed');
|
||||
localStorage.setItem('sidebarCollapsed', collapsed);
|
||||
|
||||
const calendar = document.getElementById('calendarContainer');
|
||||
if (calendar) calendar.style.display = collapsed ? 'none' : 'block';
|
||||
}
|
||||
};
|
||||
|
||||
toggle.onclick = toggleSidebar;
|
||||
const mobileBtn = document.getElementById('mobileMenuBtn');
|
||||
if (mobileBtn) mobileBtn.onclick = toggleSidebar;
|
||||
|
||||
if (overlay) {
|
||||
overlay.onclick = () => {
|
||||
sidebar.classList.remove('mobile-open');
|
||||
overlay.style.display = 'none';
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (logoutBtn) {
|
||||
logoutBtn.onclick = () => {
|
||||
if (confirm(I18nManager.t('msg_logout_confirm'))) {
|
||||
window.location.href = '/logout';
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 환경 설정 및 테마 엔진 초기화 (ThemeManager 위임)
|
||||
*/
|
||||
async initSettings() {
|
||||
return await ThemeManager.initSettings();
|
||||
},
|
||||
|
||||
/**
|
||||
* 무한 스크롤 초기화
|
||||
*/
|
||||
initInfiniteScroll(onLoadMore) {
|
||||
if (!DOM.scrollSentinel) return;
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
onLoadMore();
|
||||
}
|
||||
}, { threshold: 0.1 });
|
||||
|
||||
observer.observe(DOM.scrollSentinel);
|
||||
},
|
||||
|
||||
/**
|
||||
* 사이드바 시스템 고정 메뉴 상태 갱신
|
||||
*/
|
||||
updateSidebar(memos, activeGroup, onGroupClick) {
|
||||
if (!DOM.systemNav) return;
|
||||
|
||||
DOM.systemNav.querySelectorAll('li').forEach(li => {
|
||||
const group = li.dataset.group;
|
||||
li.className = (group === activeGroup) ? 'active' : '';
|
||||
li.onclick = () => onGroupClick(group);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 메모 목록 메인 렌더링 (서버 사이드 필터링 결과 기반)
|
||||
*/
|
||||
renderMemos(memos, filters = {}, handlers, isAppend = false) {
|
||||
if (!isAppend) {
|
||||
DOM.memoGrid.innerHTML = '';
|
||||
}
|
||||
|
||||
if (!memos || memos.length === 0) {
|
||||
if (!isAppend) {
|
||||
DOM.memoGrid.innerHTML = `<div style="grid-column: 1/-1; text-align: center; padding: 50px; color: var(--muted);">${I18nManager.t('label_no_results')}</div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
memos.forEach(memo => {
|
||||
const { className, style, innerHtml } = createMemoCardHtml(memo, memo.status === 'done');
|
||||
const card = document.createElement('div');
|
||||
card.className = className;
|
||||
card.dataset.id = memo.id; // ID 저장
|
||||
if (style) card.setAttribute('style', style);
|
||||
card.innerHTML = innerHtml;
|
||||
card.style.cursor = 'pointer';
|
||||
card.title = I18nManager.t('tooltip_edit_hint');
|
||||
card.onclick = (e) => {
|
||||
// 버튼(삭제, 핀 등) 클릭 시에는 무시
|
||||
if (e.target.closest('.action-btn')) return;
|
||||
|
||||
if (e.altKey) {
|
||||
// Alt + 클릭: 즉시 수정 모드
|
||||
handlers.onEdit(memo.id);
|
||||
} else {
|
||||
// 일반 클릭: 상세 모달 열기
|
||||
this.openMemoModal(memo.id, window.allMemosCache || memos);
|
||||
}
|
||||
};
|
||||
DOM.memoGrid.appendChild(card);
|
||||
|
||||
// 신규 카드에만 이벤트 바인딩
|
||||
this.bindCardEventsToElement(card, handlers);
|
||||
});
|
||||
|
||||
if (DOM.scrollSentinel) {
|
||||
DOM.scrollSentinel.innerText = I18nManager.t('msg_loading');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 요소(카드) 내부에 이벤트 바인딩
|
||||
*/
|
||||
bindCardEventsToElement(card, handlers) {
|
||||
const id = card.dataset.id;
|
||||
const bind = (selector, handler) => {
|
||||
const btn = card.querySelector(selector);
|
||||
if (btn) {
|
||||
btn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
handler(id);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
bind('.edit-btn', handlers.onEdit);
|
||||
bind('.delete-btn', handlers.onDelete);
|
||||
bind('.ai-btn', handlers.onAI);
|
||||
bind('.toggle-pin', handlers.onTogglePin);
|
||||
bind('.toggle-status', handlers.onToggleStatus);
|
||||
bind('.link-item', (linkId) => this.openMemoModal(linkId, window.allMemosCache || []));
|
||||
bind('.unlock-btn', handlers.onUnlock);
|
||||
},
|
||||
|
||||
/**
|
||||
* 모달 열기 위임 (ModalManager 위임)
|
||||
*/
|
||||
openMemoModal(id, memos) {
|
||||
ModalManager.openMemoModal(id, memos);
|
||||
},
|
||||
|
||||
showLoading(show) {
|
||||
DOM.loadingOverlay.style.display = show ? 'flex' : 'none';
|
||||
if (DOM.scrollSentinel) {
|
||||
DOM.scrollSentinel.style.display = show ? 'none' : 'flex';
|
||||
}
|
||||
},
|
||||
|
||||
setHasMore(hasMore) {
|
||||
if (DOM.scrollSentinel) {
|
||||
DOM.scrollSentinel.style.visibility = hasMore ? 'visible' : 'hidden';
|
||||
DOM.scrollSentinel.innerText = hasMore ? I18nManager.t('msg_loading') : I18nManager.t('msg_last_memo');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 전역 파일 다운로드 함수 (항상 전역 스코프 유지)
|
||||
*/
|
||||
window.downloadFile = async function(filename, originalName) {
|
||||
try {
|
||||
const res = await fetch(`/api/download/${filename}`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) alert(I18nManager.t('msg_permission_denied'));
|
||||
else alert(`${I18nManager.t('msg_download_failed')}: ${res.statusText}`);
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = originalName;
|
||||
document.body.appendChild(a); a.click();
|
||||
window.URL.revokeObjectURL(url); document.body.removeChild(a);
|
||||
} catch (err) { alert(`${I18nManager.t('msg_download_error')}: ` + err.message); }
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { I18nManager } from './utils/I18nManager.js';
|
||||
|
||||
/**
|
||||
* 유틸리티 기능을 담은 모듈
|
||||
*/
|
||||
|
||||
/**
|
||||
* HTML 특수 문자를 이스케이프 처리합니다.
|
||||
*/
|
||||
export function escapeHTML(str) {
|
||||
if (!str) return '';
|
||||
const charMap = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"'
|
||||
};
|
||||
return str.replace(/[&<>'"]/g, t => charMap[t] || t);
|
||||
}
|
||||
|
||||
/**
|
||||
* [[#ID]] 형태의 내부 링크를 HTML 링크로 변환합니다.
|
||||
*/
|
||||
export function parseInternalLinks(text, onLinkClick) {
|
||||
if (!text) return '';
|
||||
// 이 함수는 단순히 텍스트만 변환하며, 이벤트 바인딩은 UI 모듈에서 수행합니다.
|
||||
return text.replace(/\[\[#(\d+)\]\]/g, (match, id) => {
|
||||
const prefix = I18nManager.t('label_memo_id_prefix');
|
||||
return `<a href="javascript:void(0)" class="internal-link" data-id="${id}">${prefix}${id}</a>`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 내의 상대 경로 이미지 src를 서버 API 경로(/api/download/...)로 변환합니다.
|
||||
*/
|
||||
export function fixImagePaths(html) {
|
||||
if (!html) return '';
|
||||
// src="image.png" 같이 상대 경로로 시작하는 경우만 가로채서 API 경로로 변경
|
||||
return html.replace(/<img\s+src="([^"\/][^":]+)"/g, (match, filename) => {
|
||||
return `<img src="/api/download/${filename}"`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 전역 시스템 상수 (Global System Constants)
|
||||
*/
|
||||
export const Constants = {
|
||||
GROUPS: {
|
||||
DEFAULT: 'default',
|
||||
FILES: 'files',
|
||||
DONE: 'done'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 커스텀 i18n 매니저 (Custom i18n Manager)
|
||||
* - 서버 설정에 따라 locale 파일을 로드하고 화면을 번역합니다.
|
||||
*/
|
||||
export const I18nManager = {
|
||||
localeData: {},
|
||||
currentLang: 'en',
|
||||
|
||||
/**
|
||||
* 초기화 및 언어 팩 로드
|
||||
*/
|
||||
async init(lang = 'en') {
|
||||
this.currentLang = lang;
|
||||
try {
|
||||
const res = await fetch(`/static/locales/${lang}.json?v=2.2`);
|
||||
if (!res.ok) throw new Error(`Locale ${lang} not found`);
|
||||
this.localeData = await res.json();
|
||||
console.log(`🌐 i18n: Language [${lang}] loaded successfully.`);
|
||||
|
||||
// 초기 로드 시 한 번 전체 적용
|
||||
this.applyTranslations();
|
||||
} catch (err) {
|
||||
console.error('❌ i18n Load Error:', err);
|
||||
// 한국어 로드 실패 시에도 영어로 폴백 시도 가능
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 키에 해당하는 번역 텍스트 또는 배열 반환
|
||||
*/
|
||||
t(key) {
|
||||
// 객체 깊은 참조 지원 (예: "groups.done")
|
||||
const value = key.split('.').reduce((obj, k) => (obj && obj[k]), this.localeData);
|
||||
return value !== undefined ? value : key; // 없으면 키 자체 반환
|
||||
},
|
||||
|
||||
/**
|
||||
* 화면 내 i18n 관련 모든 속성을 번역
|
||||
*/
|
||||
applyTranslations() {
|
||||
// 1. 일반 텍스트 번역
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.dataset.i18n;
|
||||
el.textContent = this.t(key);
|
||||
});
|
||||
|
||||
// 2. Placeholder 번역
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.dataset.i18nPlaceholder;
|
||||
el.placeholder = this.t(key);
|
||||
});
|
||||
|
||||
// 3. Title (Browser Tooltip) 번역
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.dataset.i18nTitle;
|
||||
el.title = this.t(key);
|
||||
});
|
||||
|
||||
// 4. Custom Tooltip (data-tooltip) 번역
|
||||
document.querySelectorAll('[data-i18n-tooltip]').forEach(el => {
|
||||
const key = el.dataset.i18nTooltip;
|
||||
el.setAttribute('data-tooltip', this.t(key));
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"app_name": "Brain Dogfood",
|
||||
"app_tagline": "Welcome to your intelligent knowledge base.",
|
||||
|
||||
"nav_all": "All Knowledge",
|
||||
"nav_files": "Files",
|
||||
"nav_done": "Done",
|
||||
"nav_explorer": "Knowledge Explorer",
|
||||
"nav_calendar": "Calendar",
|
||||
"nav_nebula": "Knowledge Nebula",
|
||||
"nav_logout": "Logout",
|
||||
"nav_settings": "Settings",
|
||||
"nav_toggle": "Toggle Sidebar",
|
||||
|
||||
"search_placeholder": "Search memos... (Title, Content, Tag)",
|
||||
"composer_placeholder": "Leave a fragment of knowledge...",
|
||||
"composer_placeholder_trigger": "Capture knowledge or drop files...",
|
||||
"composer_title": "Title",
|
||||
"composer_group": "Group",
|
||||
"composer_tags": "Tags (comma separated)",
|
||||
"composer_save": "Save Memo",
|
||||
"composer_discard": "Discard (Delete)",
|
||||
"composer_encrypt": "Encrypt",
|
||||
"composer_password": "Password",
|
||||
"tooltip_fold": "Fold Window (Preserve Content)",
|
||||
|
||||
"settings_title": "⚙️ Settings",
|
||||
"settings_bg": "Background Color",
|
||||
"settings_sidebar": "Sidebar Color",
|
||||
"settings_card": "Memo Card Color",
|
||||
"settings_security": "Security Border Color",
|
||||
"settings_ai_accent": "AI Accent Color",
|
||||
"settings_ai_enable": "Enable AI Features",
|
||||
"settings_lang": "Language",
|
||||
"settings_save": "Save Settings",
|
||||
"settings_reset": "Reset",
|
||||
"settings_close": "Close",
|
||||
|
||||
"msg_logout_confirm": "Are you sure you want to log out completely?",
|
||||
"msg_delete_confirm": "Are you sure you want to delete this memo? This cannot be undone.",
|
||||
"msg_save_success": "Saved successfully!",
|
||||
"msg_settings_saved": "🎨 Settings have been saved to the server!",
|
||||
"msg_ai_loading": "Gemini AI is analyzing the memo...",
|
||||
"msg_encrypted_locked": "🚫 Encryption detected. Decrypt first to modify.",
|
||||
"msg_auth_failed": "Invalid credentials. Please try again.",
|
||||
"msg_network_error": "Network instability or server error occurred.",
|
||||
"msg_confirm_discard": "Discard all current content and delete uploaded files from the server?",
|
||||
"msg_alert_password_required": "A password is required to encrypt this knowledge.",
|
||||
"msg_draft_restore_confirm": "📝 There is an auto-saved draft.\nTitle: \"{title}\"\nWould you like to restore it?",
|
||||
|
||||
"title_pin": "Pin to Top",
|
||||
"title_done": "Mark as Done",
|
||||
"title_undo": "Undo Done",
|
||||
"title_ai": "AI Analysis",
|
||||
"title_edit": "Edit",
|
||||
"title_delete": "Delete",
|
||||
"btn_unlock": "Unlock",
|
||||
"label_mentioned": "Mentioned In",
|
||||
"label_linked_memo": "Linked Memo",
|
||||
"label_no_results": "No results found.",
|
||||
"label_memo_id_prefix": "Memo #",
|
||||
"tooltip_edit_hint": "Alt + Click: Quick Edit",
|
||||
"prompt_password": "Enter password to decrypt this knowledge:",
|
||||
"msg_loading": "Loading more knowledge...",
|
||||
"msg_last_memo": "This is the last piece of knowledge.",
|
||||
"msg_permission_denied": "🚫 Access Denied. Decrypt the knowledge first.",
|
||||
"msg_download_failed": "❌ Download failed",
|
||||
"msg_download_error": "📦 Error during download",
|
||||
|
||||
"login_title": "Secure Login",
|
||||
"login_welcome": "Welcome to your intelligent knowledge base.",
|
||||
"login_id": "Auth ID",
|
||||
"login_pw": "Password",
|
||||
"login_btn": "Enter System",
|
||||
"msg_authenticating": "Authenticating...",
|
||||
|
||||
"drawer_title_groups": "📁 Groups",
|
||||
"drawer_title_tags": "🏷️ Tags",
|
||||
"label_untitled": "Untitled",
|
||||
"label_ai_summary": "AI Summary",
|
||||
"label_heatmap_title": "Knowledge Growth",
|
||||
"label_more": "More",
|
||||
"label_less": "Less",
|
||||
"tooltip_heatmap_stat": "{date}: {count} items",
|
||||
"label_asset_management": "📁 Asset Management",
|
||||
"label_asset_hint": "Click: Go to memo / Alt+Click: Download",
|
||||
"label_no_assets": "No files uploaded yet.",
|
||||
"label_memo_ref": "Memo: ",
|
||||
"label_no_memo_ref": "No linked memo",
|
||||
"label_group_explorer": "📁 Group Explorer",
|
||||
"label_tag_explorer": "🏷️ Tag Explorer",
|
||||
"label_last_updated": "Last updated: ",
|
||||
|
||||
"shortcuts_label": "⌨️ Shortcuts",
|
||||
"shortcut_save": "Save",
|
||||
"shortcut_new": "New",
|
||||
"shortcut_nebula": "Nebula",
|
||||
"shortcut_slash": "Slash commands",
|
||||
"shortcut_edit": "Quick Edit",
|
||||
|
||||
"slash": {
|
||||
"task": "Task List",
|
||||
"bullet": "Bullet List",
|
||||
"number": "Ordered List",
|
||||
"quote": "Block Quote",
|
||||
"line": "Divider",
|
||||
"code": "Code Block",
|
||||
"h1": "Heading 1",
|
||||
"h2": "Heading 2",
|
||||
"h3": "Heading 3",
|
||||
"ai_summary": "AI Summary",
|
||||
"ai_tags": "AI Tags"
|
||||
},
|
||||
|
||||
"calendar_months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
|
||||
"calendar_days": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
|
||||
"date_month_year": "{month} {year}",
|
||||
|
||||
"heatmap_ranges": {
|
||||
"365": "1 Year",
|
||||
"180": "6 Months",
|
||||
"90": "3 Months",
|
||||
"30": "1 Month"
|
||||
},
|
||||
"label_select_range": "Select Range",
|
||||
|
||||
"groups": {
|
||||
"all": "All Knowledge",
|
||||
"default": "Default",
|
||||
"files": "Files",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"app_name": "뇌사료",
|
||||
"app_tagline": "지식 창고에 오신 것을 환영합니다.",
|
||||
|
||||
"nav_all": "전체 지식",
|
||||
"nav_files": "파일 모음",
|
||||
"nav_done": "완료 모음",
|
||||
"nav_explorer": "지식 탐색기",
|
||||
"nav_calendar": "달력 탐색",
|
||||
"nav_nebula": "지식 맵 보기",
|
||||
"nav_logout": "로그아웃",
|
||||
"nav_settings": "환경 설정",
|
||||
"nav_toggle": "사이드바 토글",
|
||||
|
||||
"search_placeholder": "메모 검색... (제목, 내용, 태그)",
|
||||
"composer_placeholder": "지식의 파편을 남겨주세요...",
|
||||
"composer_placeholder_trigger": "메모를 기록하거나 파일을 던져보세요...",
|
||||
"composer_title": "제목",
|
||||
"composer_group": "그룹명",
|
||||
"composer_tags": "태그 (쉼표로 구분)",
|
||||
"composer_save": "메모 저장",
|
||||
"composer_discard": "취소 (삭제)",
|
||||
"composer_encrypt": "암호화",
|
||||
"composer_password": "비밀번호",
|
||||
"tooltip_fold": "창 접기 (내용 보존)",
|
||||
|
||||
"settings_title": "⚙️ 환경 설정",
|
||||
"settings_bg": "전체 배경색",
|
||||
"settings_sidebar": "사이드바 색상",
|
||||
"settings_card": "메모지 색상",
|
||||
"settings_security": "보안 테두리색",
|
||||
"settings_ai_accent": "AI 분석 강조색",
|
||||
"settings_ai_enable": "AI 기능 활성화",
|
||||
"settings_lang": "언어 설정",
|
||||
"settings_save": "저장",
|
||||
"settings_reset": "초기화",
|
||||
"settings_close": "닫기",
|
||||
|
||||
"msg_logout_confirm": "완전하게 로그아웃하시겠습니까?",
|
||||
"msg_delete_confirm": "이 메모를 정말 삭제할까요? 되돌릴 수 없습니다.",
|
||||
"msg_save_success": "저장되었습니다!",
|
||||
"msg_settings_saved": "🎨 테마 설정이 서버에 저장되었습니다!",
|
||||
"msg_ai_loading": "Gemini AI가 메모를 분석 중입니다...",
|
||||
"msg_encrypted_locked": "🚫 암호화된 메모입니다. 먼저 해독하세요.",
|
||||
"msg_auth_failed": "올바른 자격 증명이 아닙니다. 다시 시도해 주세요.",
|
||||
"msg_network_error": "네트워크 불안정 또는 서버 오류가 발생했습니다.",
|
||||
"msg_confirm_discard": "작성 중인 내용을 모두 지우고 업로드한 파일도 서버에서 삭제할까요?",
|
||||
"msg_alert_password_required": "암호화하려면 비밀번호를 입력해야 합니다.",
|
||||
"msg_draft_restore_confirm": "📝 임시 저장된 메모가 있습니다.\n제목: \"{title}\"\n복원하시겠습니까?",
|
||||
|
||||
"title_pin": "중요 (상단 고정)",
|
||||
"title_done": "완료 처리",
|
||||
"title_undo": "다시 활성화",
|
||||
"title_ai": "AI 분석",
|
||||
"title_edit": "수정",
|
||||
"title_delete": "삭제",
|
||||
"btn_unlock": "해독하기",
|
||||
"label_mentioned": "언급됨",
|
||||
"label_no_results": "조회 결과가 없습니다.",
|
||||
"label_memo_id_prefix": "메모 #",
|
||||
"tooltip_edit_hint": "Alt + 클릭: 즉시 수정",
|
||||
"prompt_password": "이 지식을 해독할 비밀번호를 입력하세요:",
|
||||
"msg_loading": "더 많은 지식을 불러오는 중...",
|
||||
"msg_last_memo": "마지막 지식입니다.",
|
||||
"msg_permission_denied": "🚫 접근 권한 부족. 먼저 지식을 해독하세요.",
|
||||
"msg_download_failed": "❌ 다운로드 실패",
|
||||
"msg_download_error": "📦 다운로드 중 오류",
|
||||
|
||||
"login_title": "보안 로그인",
|
||||
"login_welcome": "지능형 지식 창고에 오신 것을 환영합니다.",
|
||||
"login_id": "인증 아이디",
|
||||
"login_pw": "비밀번호",
|
||||
"login_btn": "보안 시스템 접속",
|
||||
"msg_authenticating": "인증 중...",
|
||||
|
||||
"drawer_title_groups": "📁 그룹",
|
||||
"drawer_title_tags": "🏷️ 태그",
|
||||
"label_untitled": "무제",
|
||||
"label_ai_summary": "AI 요약",
|
||||
"label_heatmap_title": "지식 성장",
|
||||
"label_more": "많음",
|
||||
"label_less": "적음",
|
||||
"tooltip_heatmap_stat": "{date}: {count}개의 지식",
|
||||
"label_asset_management": "📁 전체 첨부파일 관리",
|
||||
"label_asset_hint": "클릭: 해당 메모로 이동 / Alt+클릭: 미리보기",
|
||||
"label_no_assets": "아직 업로드된 파일이 없습니다.",
|
||||
"label_memo_ref": "메모: ",
|
||||
"label_no_memo_ref": "연결된 메모 없음",
|
||||
"label_group_explorer": "📁 그룹별 탐색",
|
||||
"label_tag_explorer": "🏷️ 태그별 탐색",
|
||||
"label_last_updated": "마지막 수정: ",
|
||||
|
||||
"shortcuts_label": "⌨️ 단축키",
|
||||
"shortcut_save": "저장",
|
||||
"shortcut_new": "새 메모",
|
||||
"shortcut_nebula": "네뷸라",
|
||||
"shortcut_slash": "슬래시 명령",
|
||||
"shortcut_edit": "즉시 수정",
|
||||
|
||||
"slash": {
|
||||
"task": "체크박스",
|
||||
"bullet": "목록",
|
||||
"number": "번호 목록",
|
||||
"quote": "인용",
|
||||
"line": "구분선",
|
||||
"code": "코드 블록",
|
||||
"h1": "제목 1",
|
||||
"h2": "제목 2",
|
||||
"h3": "제목 3",
|
||||
"ai_summary": "AI 요약",
|
||||
"ai_tags": "AI 태그 추출"
|
||||
},
|
||||
|
||||
"calendar_months": ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"],
|
||||
"calendar_days": ["일", "월", "화", "수", "목", "금", "토"],
|
||||
"date_month_year": "{year}년 {month}",
|
||||
|
||||
"heatmap_ranges": {
|
||||
"365": "1년",
|
||||
"180": "6개월",
|
||||
"90": "3개월",
|
||||
"30": "1개월"
|
||||
},
|
||||
"label_select_range": "기간 선택",
|
||||
|
||||
"groups": {
|
||||
"all": "전체 지식",
|
||||
"default": "기본",
|
||||
"files": "파일모음",
|
||||
"done": "완료모음"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* 🧠 뇌사료(Memo Server) Main Stylesheet
|
||||
- Modularized Architecture (v4.0)
|
||||
*/
|
||||
|
||||
/* 1. Global Variables & Reset */
|
||||
@import url('./css/variables.css');
|
||||
|
||||
/* 2. Base Layout Shell */
|
||||
@import url('./css/layout.css');
|
||||
|
||||
/* 3. Core Components Styles */
|
||||
@import url('./css/sidebar.css');
|
||||
@import url('./css/components/memo.css');
|
||||
@import url('./css/components/editor.css');
|
||||
@import url('./css/components/modals.css');
|
||||
|
||||
/* 4. Visualization & Navigation (Calendar, Drawer, Map) */
|
||||
@import url('./css/components/visualization.css');
|
||||
@import url('./css/components/heatmap.css');
|
||||
|
||||
/* 5. Slash Command Popup */
|
||||
@import url('./css/components/slash-command.css');
|
||||
|
||||
/* 6. Mobile Responsive Overrides */
|
||||
@import url('./css/mobile.css');
|
||||
@@ -0,0 +1,219 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title data-i18n="app_name">Brain Dogfood</title>
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Toast UI Editor -->
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" />
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/theme/toastui-editor-dark.min.css" />
|
||||
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
|
||||
|
||||
<!-- TUI Color Picker & Color Syntax Plugin -->
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/tui-color-picker/latest/tui-color-picker.min.css" />
|
||||
<script src="https://uicdn.toast.com/tui-color-picker/latest/tui-color-picker.min.js"></script>
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor-plugin-color-syntax/latest/toastui-editor-plugin-color-syntax.min.css" />
|
||||
<script src="https://uicdn.toast.com/editor-plugin-color-syntax/latest/toastui-editor-plugin-color-syntax.min.js"></script>
|
||||
|
||||
<!-- D3.js for Knowledge Nebula -->
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.4">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header" style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 2.5rem;">
|
||||
<h1 class="logo">🧠 <span class="text" data-i18n="app_name">Brain Dogfood</span></h1>
|
||||
<button id="sidebarToggle" class="sidebar-toggle" data-i18n-title="nav_toggle">☰</button>
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<ul class="nav" id="systemNav">
|
||||
<li class="active" data-group="all" data-i18n-title="nav_all"><span class="icon">💡</span> <span class="text" data-i18n="nav_all">All Knowledge</span></li>
|
||||
<li data-group="files" data-i18n-title="nav_files"><span class="icon">📂</span> <span class="text" data-i18n="nav_files">Files</span></li>
|
||||
<li data-group="done" data-i18n-title="nav_done"><span class="icon">✅</span> <span class="text" data-i18n="nav_done">Done</span></li>
|
||||
</ul>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<button id="openExplorerBtn" class="action-btn explorer-btn" style="width: 100%; justify-content: flex-start; margin-top: 15px; padding: 12px 15px; background: rgba(56, 189, 248, 0.1); border: 1px solid rgba(56, 189, 248, 0.2); color: var(--accent); font-weight: 600; border-radius: 12px;">
|
||||
<span class="icon">🔍</span> <span class="text" data-i18n="nav_explorer">Knowledge Explorer</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div id="calendarHeader" class="section-title" style="cursor: pointer; display: flex; align-items: center; justify-content: space-between; padding: 10px 15px; border-radius: 8px; margin-top: 10px; transition: background 0.2s;">
|
||||
<span style="font-size: 0.9rem; font-weight: 600; color: var(--muted);"><span class="icon">📅</span> <span class="text" data-i18n="nav_calendar">Calendar</span></span>
|
||||
<span id="calendarToggleIcon" style="font-size: 0.8rem; color: var(--muted);">▲</span>
|
||||
</div>
|
||||
<div id="calendarContainer" class="calendar-content">
|
||||
<!-- JS will render calendar here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div id="heatmapContainer">
|
||||
<!-- JS will render heatmap here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<button id="openGraphBtn" class="action-btn" style="width: 100%; justify-content: flex-start; margin-top: 10px; padding: 10px 15px; background: rgba(139, 92, 246, 0.1); border: 1px solid rgba(139, 92, 246, 0.2); color: var(--ai-accent);">
|
||||
<span class="icon">🕸️</span> <span class="text" data-i18n="nav_nebula">Knowledge Nebula</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button id="logoutBtn" class="action-btn" style="color: #ff4d4d;" data-i18n-tooltip="tooltip_logout">
|
||||
<span class="icon">🚪</span> <span class="text" data-i18n="nav_logout">Logout</span>
|
||||
</button>
|
||||
<button id="settingsBtn" class="action-btn" data-i18n-tooltip="tooltip_settings">
|
||||
<span class="icon">⚙️</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="content">
|
||||
<div class="topbar">
|
||||
<button id="mobileMenuBtn" class="sidebar-toggle" style="display: none; margin-right: 15px;">☰</button>
|
||||
<div class="search-bar">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input type="text" id="searchInput" data-i18n-placeholder="search_placeholder">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer-wrapper">
|
||||
<!-- Accordion default closed state -->
|
||||
<div id="composerTrigger" class="glass-panel" style="cursor: text;">
|
||||
<span style="color: var(--muted); font-size: 1.1rem; font-weight: 600;" data-i18n="composer_placeholder_trigger">Capture knowledge or drop files...</span>
|
||||
</div>
|
||||
|
||||
<!-- Actual Composer -->
|
||||
<form id="composer" class="glass-panel" style="display: none;">
|
||||
<input type="hidden" id="editingMemoId" value="">
|
||||
|
||||
<div style="display: flex; gap:10px; align-items:center; margin-bottom: 10px;">
|
||||
<input type="text" id="memoTitle" data-i18n-placeholder="composer_title" autocomplete="off" style="flex: 1;">
|
||||
<button type="button" id="foldBtn" class="action-btn" style="height:35px; width:35px; padding:0;" data-i18n-title="tooltip_fold">▲</button>
|
||||
</div>
|
||||
|
||||
<div class="meta-inputs" style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center;">
|
||||
<input type="text" id="memoGroup" data-i18n-placeholder="composer_group" class="meta-field" style="width: 120px;">
|
||||
<input type="text" id="memoTags" data-i18n-placeholder="composer_tags" class="meta-field" style="flex: 1;">
|
||||
<button type="button" id="encryptionToggle" class="action-btn" data-i18n-title="composer_encrypt" style="height:34px; padding:0 10px;">🔓</button>
|
||||
<input type="password" id="memoPassword" data-i18n-placeholder="composer_password" class="meta-field" style="width: 120px; display: none;">
|
||||
</div>
|
||||
|
||||
<div class="editor-resize-wrapper">
|
||||
<div id="editor"></div>
|
||||
</div>
|
||||
<!-- Pending Attachments list in Composer -->
|
||||
<div id="editorAttachments" class="memo-attachments" style="margin-top: 15px;"></div>
|
||||
|
||||
<!-- 키보드 단축키 힌트 (토글) -->
|
||||
<div id="shortcutHint" class="shortcut-hint-bar">
|
||||
<button type="button" id="shortcutToggle" class="shortcut-toggle-btn" data-i18n="shortcuts_label">⌨️ Shortcuts</button>
|
||||
<div id="shortcutDetails" class="shortcut-details" style="display: none;">
|
||||
<span class="sk"><kbd>Ctrl</kbd>+<kbd>Enter</kbd> <span data-i18n="shortcut_save">Save</span></span>
|
||||
<span class="sk"><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd> / <kbd>Alt</kbd>+<kbd>`</kbd> <span data-i18n="shortcut_new">New Memo</span></span>
|
||||
<span class="sk"><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd> <span data-i18n="shortcut_nebula">Nebula</span></span>
|
||||
<span class="sk"><kbd>/</kbd> <span data-i18n="shortcut_slash">Slash Commands</span></span>
|
||||
<span class="sk"><kbd>Alt</kbd>+<kbd>Click</kbd> <span data-i18n="shortcut_edit">Quick Edit</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer-actions" style="display: flex; gap: 10px; margin-top: 15px; justify-content: flex-end;">
|
||||
<button type="button" id="discardBtn" class="action-btn" style="background: rgba(255, 77, 77, 0.1); color: #ff4d4d; border-color: rgba(255, 77, 77, 0.2);" data-i18n="composer_discard">Discard (Delete)</button>
|
||||
<button type="submit" id="submitBtn" class="primary-btn" data-i18n="composer_save">Save Memo</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="masonry-grid" id="memoGrid">
|
||||
<!-- Memos loaded here -->
|
||||
</div>
|
||||
<div id="scrollSentinel" style="height: 50px; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 0.9rem;">
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal for viewing memo details/links -->
|
||||
<div id="memoModal" class="modal">
|
||||
<div class="modal-content glass-panel" id="modalContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settingsModal" class="modal">
|
||||
<div class="modal-content glass-panel" style="max-width: 400px; padding: 25px;">
|
||||
<h2 style="margin-bottom: 20px; font-weight: 800; background: linear-gradient(135deg, #38bdf8, #8b5cf6); -webkit-background-clip: text; -webkit-text-fill-color: transparent;" data-i18n="settings_title">⚙️ Settings</h2>
|
||||
|
||||
<div class="settings-grid">
|
||||
<label data-i18n="settings_bg">전체 배경색</label>
|
||||
<input type="color" id="set-bg" data-var="--bg">
|
||||
|
||||
<label data-i18n="settings_sidebar">사이드바 색상</label>
|
||||
<input type="color" id="set-sidebar" data-var="--sidebar">
|
||||
|
||||
<label data-i18n="settings_card">메모지 색상</label>
|
||||
<input type="color" id="set-card" data-var="--card">
|
||||
|
||||
<label data-i18n="settings_security">보안 테두리색</label>
|
||||
<input type="color" id="set-encrypted" data-var="--encrypted-border">
|
||||
|
||||
<label data-i18n="settings_ai_accent">AI 분석 강조색</label>
|
||||
<input type="color" id="set-ai" data-var="--ai-accent">
|
||||
|
||||
<label style="font-weight: 800; color: var(--ai-accent);" data-i18n="settings_ai_enable">AI 기능 활성화</label>
|
||||
<input type="checkbox" id="set-enable-ai" style="width: 20px; height: 20px; cursor: pointer;">
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<label data-i18n="settings_lang">언어 설정</label>
|
||||
<select id="set-lang" class="meta-field" style="width: 100px;">
|
||||
<option value="ko">한국어</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="settings-actions">
|
||||
<button id="resetThemeBtn" class="action-btn" style="font-size: 0.85rem;" data-i18n="settings_reset">Reset</button>
|
||||
<button id="saveThemeBtn" class="primary-btn" data-i18n="settings_save">Save</button>
|
||||
<button id="closeSettingsBtn" class="action-btn" data-i18n="settings_close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Loading Overlay (Optional but nice) -->
|
||||
<div id="loadingOverlay" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); backdrop-filter:blur(5px); z-index:2000; flex-direction:column; justify-content:center; align-items:center;">
|
||||
<div class="spinner"></div>
|
||||
<p style="margin-top:20px; font-weight:800; color:var(--accent);" data-i18n="msg_ai_loading">AI is analyzing the memo...</p>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Overlay for mobile -->
|
||||
<div id="sidebarOverlay" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.4); z-index:900; backdrop-filter:blur(2px);"></div>
|
||||
|
||||
<!-- Graph Modal -->
|
||||
<div id="graphModal" class="modal">
|
||||
<div class="modal-content glass-panel" style="width: 90%; height: 90%; max-width: none; overflow: hidden; position: relative; padding: 0; background: radial-gradient(circle at center, #1e293b 0%, #0f172a 100%);">
|
||||
<div id="graphContainer" style="width: 100%; height: 100%; background-image: radial-gradient(circle, rgba(255,255,255,0.05) 1px, transparent 1px); background-size: 50px 50px;"></div>
|
||||
<button id="closeGraphBtn" style="position: absolute; top: 20px; right: 20px; z-index: 100; background: rgba(0,0,0,0.5); border: none; color: white; border-radius: 50%; width: 40px; height: 40px; cursor: pointer; font-size: 20px;">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Knowledge Explorer Drawer -->
|
||||
<div id="knowledgeDrawer" class="drawer">
|
||||
<div class="drawer-header">
|
||||
<h3 data-i18n="nav_explorer">🔍 Knowledge Explorer</h3>
|
||||
<button id="closeDrawerBtn" class="close-btn">×</button>
|
||||
</div>
|
||||
<div id="drawerContent" class="drawer-body">
|
||||
<!-- Groups/Tags will be injected here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/static/app.js?v=2.2"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title data-i18n="login_title">뇌사료 | 보안 로그인</title>
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/login.css?v=1.0">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="logo-area">
|
||||
<h1 class="logo">🧠 <span data-i18n="app_name">Brain Dogfood</span></h1>
|
||||
<p class="tagline" data-i18n="login_welcome">Welcome to your intelligent knowledge base.</p>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="username" data-i18n="login_id">Auth ID</label>
|
||||
<div class="input-wrapper">
|
||||
<input type="text" id="username" placeholder="Username" autocomplete="username" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="password" data-i18n="login_pw">Password</label>
|
||||
<div class="input-wrapper">
|
||||
<input type="password" id="password" placeholder="••••••••" autocomplete="current-password" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="login-btn" id="loginBtn" data-i18n="login_btn">Login</button>
|
||||
<div class="error-msg" id="errorMsg" data-i18n="msg_auth_failed" style="display:none;">Authentication failed.</div>
|
||||
|
||||
<div class="login-card-footer">
|
||||
© 2026 개밥마스터 Personal Knowledge Base.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { I18nManager } from '/static/js/utils/I18nManager.js';
|
||||
|
||||
const lang = "{{ lang }}"; // Server-injected lang
|
||||
await I18nManager.init(lang);
|
||||
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const passwordInput = document.getElementById('password');
|
||||
const errorMsg = document.getElementById('errorMsg');
|
||||
|
||||
loginBtn.addEventListener('click', async () => {
|
||||
const username = usernameInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
|
||||
if (!username || !password) return;
|
||||
|
||||
errorMsg.style.display = 'none';
|
||||
loginBtn.innerText = I18nManager.t('msg_authenticating');
|
||||
loginBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
errorMsg.style.display = 'block';
|
||||
errorMsg.innerText = data.error || I18nManager.t('msg_auth_failed');
|
||||
loginBtn.innerText = I18nManager.t('login_btn');
|
||||
loginBtn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(I18nManager.t('msg_network_error'));
|
||||
loginBtn.innerText = I18nManager.t('login_btn');
|
||||
loginBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Enter key to login
|
||||
[usernameInput, passwordInput].forEach(input => {
|
||||
input.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') loginBtn.click();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user