commit 175a30325bf50b052d7d06abd3859020520db221 Author: leeyj Date: Thu Apr 16 01:12:43 2026 +0900 Initial Global Release v1.0 (Localization & Security Hardening) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a4f4571 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eddbc12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Brain Dogfood Public Repo Gitignore +.env +config.json +memos.db +__pycache__/ +*.pyc +logs/ +data/ +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9037663 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +[한국어](#한국어) | [English](#english) + +
+ +
+ Brain Dogfood Dashboard +

🧠 뇌사료 (Brain Dogfood)

+

지식을 기록하는 습관을 넘어, 지능형 유기체로 성장하는 나만의 지식 창고

+

Minimalist, AI-powered, Privacy-first Knowledge Server

+
+ +--- + +> [!IMPORTANT] +> **보안 주의사항 (Security Notice)** +> - 기본 관리자 계정은 아이디: `admin` / 비밀번호: `.env` 파일에서 본인이 설정한 값입니다. +> - 최초 로그인 후, 혹은 서버 실행 전 **`.env` 파일에서 `ADMIN_USERNAME`과 `ADMIN_PASSWORD`를 반드시 본인만의 정보로 수정**하세요. 수정하지 않을 경우 보안에 매우 취약해질 수 있습니다. + +> [!NOTE] +> **AI 기능은 선택 사항입니다 (AI is Optional)** +> - **Gemini API 키가 없어도** 뇌사료의 핵심 기능(기본 메모, 히트맵, 지식 그래프 Nebula, 개별 암호화 등)은 **모두 정상 작동**합니다. +> - AI 기능(`GEMINI_API_KEY`)은 자동 요약과 인공지능 태깅 기능을 사용할 때만 필요합니다. + +--- + +

📄 프로젝트 소개

+ +**뇌사료(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`를 등록하세요.* + +--- + +

🌐 English Description

+ +### 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). + +--- +
+

Developed with ❤️ for knowledge lovers.

+
diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..f7ffbf9 --- /dev/null +++ b/app/__init__.py @@ -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 diff --git a/app/ai.py b/app/ai.py new file mode 100644 index 0000000..fe89f6b --- /dev/null +++ b/app/ai.py @@ -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, [] diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..b5ce1ec --- /dev/null +++ b/app/auth.py @@ -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 diff --git a/app/constants.py b/app/constants.py new file mode 100644 index 0000000..ea46c6b --- /dev/null +++ b/app/constants.py @@ -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] diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..be6f4b4 --- /dev/null +++ b/app/database.py @@ -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 diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..18fad23 --- /dev/null +++ b/app/routes/__init__.py @@ -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) diff --git a/app/routes/ai.py b/app/routes/ai.py new file mode 100644 index 0000000..4e62340 --- /dev/null +++ b/app/routes/ai.py @@ -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//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() diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..7fd1a4d --- /dev/null +++ b/app/routes/auth.py @@ -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')) diff --git a/app/routes/file.py b/app/routes/file.py new file mode 100644 index 0000000..863f6d2 --- /dev/null +++ b/app/routes/file.py @@ -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/') +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/', 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() diff --git a/app/routes/main.py b/app/routes/main.py new file mode 100644 index 0000000..04d4cb2 --- /dev/null +++ b/app/routes/main.py @@ -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) diff --git a/app/routes/memo.py b/app/routes/memo.py new file mode 100644 index 0000000..a4f3b73 --- /dev/null +++ b/app/routes/memo.py @@ -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/', 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/', 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//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}) diff --git a/app/routes/settings.py b/app/routes/settings.py new file mode 100644 index 0000000..ebf0774 --- /dev/null +++ b/app/routes/settings.py @@ -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 diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..52aea14 --- /dev/null +++ b/app/security.py @@ -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 diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..cc92bc3 --- /dev/null +++ b/app/utils/__init__.py @@ -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'(? **"지식은 기록될 때 힘을 얻고, 연결될 때 생명을 얻는다."** + +본 프로젝트는 개인용 지식창고 및 메모 서버로, **보안**, **지능형 연결(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* diff --git a/docs/api_reference.md b/docs/api_reference.md new file mode 100644 index 0000000..4dc5366 --- /dev/null +++ b/docs/api_reference.md @@ -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//decrypt` | 비밀번호 검증 및 본문 일시 복호화 | +| `GET` | `/api/stats/heatmap` | 최근 N일간의 일자별 메모 작성 수(통계) 조회 (`days` 파라미터 지원) | + +### 2.2 Assets (제한적 접근) +| Method | URL | Security Policy | Description | +| :--- | :--- | :--- | :--- | +| `GET` | `/api/download/` | **세션 필수(로그인 상호작용)** | 이미지/파일 다운로드. 이미지인 경우 `inline` 처리 및 암호화 메모 관련 파일은 로그인 미달 시 403 차단. | +| `POST` | `/api/upload` | `login_required` | 파일 업로드 및 서버 측 마스터 키 암호화 저장. | + +### 2.3 Settings & Ops (v11.0 추가) +| Method | URL | Description | +| :--- | :--- | :--- | +| `GET` | `/api/settings` | 서버 사이드 테마 및 전역 설정 조회 | +| `POST` | `/api/settings` | UI 테마 설정을 서버에 영구 기록 | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..c6e1b06 --- /dev/null +++ b/docs/architecture.md @@ -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)만으로 즉시 복구가 가능한 구조를 갖췄습니다. diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..0b853d6 --- /dev/null +++ b/docs/features.md @@ -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: - **지각적 설계**: 다크 모드 환경에서도 가독성이 뛰어난 색상 팔레트를 우선적으로 제공합니다. diff --git a/docs/img/main.png b/docs/img/main.png new file mode 100644 index 0000000..b080cba Binary files /dev/null and b/docs/img/main.png differ diff --git a/docs/logic_flow.md b/docs/logic_flow.md new file mode 100644 index 0000000..7da4a9a --- /dev/null +++ b/docs/logic_flow.md @@ -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`)로 통합합니다. 이 세 가지만 있으면 서버가 완전히 붕괴되어도 다른 환경에서 즉시 복호화 및 서비스 재개가 가능합니다. diff --git a/docs/shortcuts.md b/docs/shortcuts.md new file mode 100644 index 0000000..4910057 --- /dev/null +++ b/docs/shortcuts.md @@ -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'의 콘솔창 호출 방식에서 유래한 것으로, 영감이 떠오른 순간 가장 빠르게 기록을 시작할 수 있는 방법입니다. diff --git a/docs/source_mapping.md b/docs/source_mapping.md new file mode 100644 index 0000000..d033bfc --- /dev/null +++ b/docs/source_mapping.md @@ -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/` (보안 하향 링크) +- **Caller**: `ModalManager.js` (Inline Images) or `AttachmentBox.js` +- **Security**: `session['logged_in']` 확인 -> `is_encrypted` 상태에 따른 접근 제어. +- **Header**: 이미지인 경우 `Content-Disposition: inline`. + +### 3.2 `PUT /api/memos/` (수정 및 보안 전이) +- **Status Change**: 암호화 해제 저장 시 `is_encrypted: 0`으로 DB 상태 업데이트. +- **Process**: `memo.py:update_memo`에서 `password` 유무에 따른 Re-encryption 수행. diff --git a/docs/user_manual.md b/docs/user_manual.md new file mode 100644 index 0000000..66e551c --- /dev/null +++ b/docs/user_manual.md @@ -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 +``` + +--- + +**지식의 우주를 마음껏 탐험하세요!** 🛸🌌 diff --git a/docs/v2_mobile_roadmap.md b/docs/v2_mobile_roadmap.md new file mode 100644 index 0000000..7ae256a --- /dev/null +++ b/docs/v2_mobile_roadmap.md @@ -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* diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..523c7e0 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..e590d9e --- /dev/null +++ b/static/app.js @@ -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); +}); diff --git a/static/css/components/editor.css b/static/css/components/editor.css new file mode 100644 index 0000000..c30b274 --- /dev/null +++ b/static/css/components/editor.css @@ -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); } +} diff --git a/static/css/components/heatmap.css b/static/css/components/heatmap.css new file mode 100644 index 0000000..4d332c0 --- /dev/null +++ b/static/css/components/heatmap.css @@ -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; +} diff --git a/static/css/components/memo.css b/static/css/components/memo.css new file mode 100644 index 0000000..6f04c41 --- /dev/null +++ b/static/css/components/memo.css @@ -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); +} diff --git a/static/css/components/modals.css b/static/css/components/modals.css new file mode 100644 index 0000000..d85a439 --- /dev/null +++ b/static/css/components/modals.css @@ -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 */ +} diff --git a/static/css/components/slash-command.css b/static/css/components/slash-command.css new file mode 100644 index 0000000..6fb5ec5 --- /dev/null +++ b/static/css/components/slash-command.css @@ -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); + } +} diff --git a/static/css/components/visualization.css b/static/css/components/visualization.css new file mode 100644 index 0000000..633826e --- /dev/null +++ b/static/css/components/visualization.css @@ -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; } diff --git a/static/css/layout.css b/static/css/layout.css new file mode 100644 index 0000000..cfb4c4a --- /dev/null +++ b/static/css/layout.css @@ -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; } diff --git a/static/css/login.css b/static/css/login.css new file mode 100644 index 0000000..94ac50e --- /dev/null +++ b/static/css/login.css @@ -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); +} diff --git a/static/css/mobile.css b/static/css/mobile.css new file mode 100644 index 0000000..de89a95 --- /dev/null +++ b/static/css/mobile.css @@ -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); } +} diff --git a/static/css/sidebar.css b/static/css/sidebar.css new file mode 100644 index 0000000..ae83031 --- /dev/null +++ b/static/css/sidebar.css @@ -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; +} diff --git a/static/css/variables.css b/static/css/variables.css new file mode 100644 index 0000000..4b73f86 --- /dev/null +++ b/static/css/variables.css @@ -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; +} diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000..befe021 Binary files /dev/null and b/static/favicon.png differ diff --git a/static/js/AppService.js b/static/js/AppService.js new file mode 100644 index 0000000..f095863 --- /dev/null +++ b/static/js/AppService.js @@ -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); + } + } +}; diff --git a/static/js/api.js b/static/js/api.js new file mode 100644 index 0000000..0b1ca03 --- /dev/null +++ b/static/js/api.js @@ -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(); + } +}; diff --git a/static/js/components/AttachmentBox.js b/static/js/components/AttachmentBox.js new file mode 100644 index 0000000..9357c18 --- /dev/null +++ b/static/js/components/AttachmentBox.js @@ -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 = '
'; + attachments.forEach(a => { + const icon = getFileIcon(a.file_type || ''); + html += ` + + ${icon} + ${escapeHTML(a.original_name)} + `; + }); + html += '
'; + return html; +} diff --git a/static/js/components/CalendarManager.js b/static/js/components/CalendarManager.js new file mode 100644 index 0000000..6bb364a --- /dev/null +++ b/static/js/components/CalendarManager.js @@ -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 = ` +
+
+ + ${monthYearHeader} + +
+
+ ${dayLabels.map(day => `
${day}
`).join('')} + `; + + // 이전 달 날짜들 + for (let i = firstDay - 1; i >= 0; i--) { + html += `
${prevDaysInMonth - i}
`; + } + + // 현재 달 날짜들 + 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 += ` +
+ ${day} + ${hasMemo ? '' : ''} +
+ `; + } + + html += `
`; + 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); + }; + }); + } +}; diff --git a/static/js/components/ComposerManager.js b/static/js/components/ComposerManager.js new file mode 100644 index 0000000..70f2802 --- /dev/null +++ b/static/js/components/ComposerManager.js @@ -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'); + } +}; diff --git a/static/js/components/DrawerManager.js b/static/js/components/DrawerManager.js new file mode 100644 index 0000000..e6ad0e0 --- /dev/null +++ b/static/js/components/DrawerManager.js @@ -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 = `

${I18nManager.t('label_no_results')}

`; + 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 = ` +
+

${I18nManager.t('drawer_title_groups')}

+
+
+ 💡 ${I18nManager.t('nav_all')} ${groupCounts[groupAllKey]} +
+ ${sortedGroups.map(g => ` +
+ 📁 ${escapeHTML(g)} ${groupCounts[g]} +
+ `).join('')} +
+
+ +
+

${I18nManager.t('drawer_title_tags')}

+
+ ${sortedTags.map(t => ` +
+ ${t.source === 'ai' ? '🪄' : '🏷️'} ${escapeHTML(t.name)} ${t.count} +
+ `).join('')} +
+
+ `; + + 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'); + } + } +}; diff --git a/static/js/components/HeatmapManager.js b/static/js/components/HeatmapManager.js new file mode 100644 index 0000000..5b6bd03 --- /dev/null +++ b/static/js/components/HeatmapManager.js @@ -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 = ` +
+
+ ${heatmapTitle} + +
+
+ `; + + 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 += ` +
+
+ `; + } + + html += ` +
+
+ ${labelLess} +
+
+
+
+
+ ${labelMore} +
+
+ `; + + 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(); + }; + } + } +}; diff --git a/static/js/components/MemoCard.js b/static/js/components/MemoCard.js new file mode 100644 index 0000000..67e0724 --- /dev/null +++ b/static/js/components/MemoCard.js @@ -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 = `
${I18nManager.t('label_ai_summary')}: ${escapeHTML(memo.summary)}
`; + } + } + + const titleHtml = memo.title ? `

${escapeHTML(memo.title)}

` : ''; + + let htmlContent = ''; + if (!isDone) { + if (memo.is_encrypted) { + htmlContent = ` +
+ 🔒 + ${I18nManager.t('msg_encrypted_locked')} + +
+ `; + } else { + // marked로 파싱한 후 DOMPurify로 살균하여 XSS 방지 + htmlContent = DOMPurify.sanitize(marked.parse(memo.content || '')); + htmlContent = parseInternalLinks(htmlContent); + htmlContent = fixImagePaths(htmlContent); + } + } + const contentHtml = `
${htmlContent}
`; + + let metaHtml = '
'; + 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 += `📁 ${escapeHTML(groupName)}`; + } + 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 += `${t.source === 'ai' ? '🪄 ' : '#'}${escapeHTML(t.name)}`; + }); + } + metaHtml += '
'; + + let linksHtml = ''; + if (!isDone && memo.backlinks && memo.backlinks.length > 0) { + linksHtml = `'; + } + + // 암호화된 메모인 경우 해독 전까지 첨부파일 목록 숨김 + const attachmentsHtml = !memo.is_encrypted ? renderAttachmentBox(memo.attachments) : ''; + + // 암호화된 메모가 잠긴 상태라면 하단 액션 버튼(수정, 삭제, AI 등)을 아예 보여주지 않음 (보안 및 UI 겹침 방지) + const isLocked = memo.is_encrypted && (!htmlContent || htmlContent.includes('encrypted-block')); + const actionsHtml = isLocked ? '' : ` +
+ + + ${!isDone ? `` : ''} + + +
+ `; + const idBadge = `
#${memo.id}
`; + + return { + className: cardClass, + style: borderStyle, + innerHtml: idBadge + summaryHtml + titleHtml + metaHtml + contentHtml + linksHtml + attachmentsHtml + actionsHtml + }; +} diff --git a/static/js/components/ModalManager.js b/static/js/components/ModalManager.js new file mode 100644 index 0000000..2877692 --- /dev/null +++ b/static/js/components/ModalManager.js @@ -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 = ` +
+ +

${I18nManager.t('label_asset_management')}

+

${I18nManager.t('label_asset_hint')}

+
+ ${assets.length > 0 ? assets.map(a => ` +
+ ${['png','jpg','jpeg','gif','webp','svg'].includes(a.file_type?.toLowerCase()) + ? `` + : `
📎
` + } +
${escapeHTML(a.original_name)}
+
${a.memo_title ? `${I18nManager.t('label_memo_ref')}${escapeHTML(a.memo_title)}` : I18nManager.t('label_no_memo_ref')}
+
+ `).join('') : `
${I18nManager.t('label_no_assets')}
`} +
+
+ `; + 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 = ` +
+

${I18nManager.t('label_group_explorer')}

+
+
+ 💡 ${I18nManager.t('nav_all')} ${groupCounts[groupAllKey]} +
+ ${sortedGroups.map(g => ` +
+ 📁 ${escapeHTML(g)} ${groupCounts[g]} +
+ `).join('')} +
+
+ +
+

${I18nManager.t('label_tag_explorer')}

+
+ ${sortedTags.map(t => ` +
+ ${t.source === 'ai' ? '🪄' : '🏷️'} ${escapeHTML(t.name)} ${t.count} +
+ `).join('')} +
+
+ `; + + 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 = ` + + ${memo.title ? `

${escapeHTML(memo.title)}

` : ''} + + ${memo.summary ? ` +
+
+ 🪄 AI INSIGHT +
+
${escapeHTML(memo.summary)}
+
+ ` : '
'} + +
${html}
+
${I18nManager.t('label_last_updated')}${lastUpdatedTime}
+ `; + + // 닫기 버튼 이벤트 + 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); + }); + }); + } +}; diff --git a/static/js/components/SidebarUI.js b/static/js/components/SidebarUI.js new file mode 100644 index 0000000..9d562a4 --- /dev/null +++ b/static/js/components/SidebarUI.js @@ -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 = `${icon} ${escapeHTML(label)}`; + li.onclick = () => onGroupClick(group); + container.appendChild(li); + }); +} diff --git a/static/js/components/SlashCommand.js b/static/js/components/SlashCommand.js new file mode 100644 index 0000000..473d62b --- /dev/null +++ b/static/js/components/SlashCommand.js @@ -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) => ` +
+ ${c.icon} + ${c.label} +
+ `).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); + } + } + } +}; diff --git a/static/js/components/ThemeManager.js b/static/js/components/ThemeManager.js new file mode 100644 index 0000000..8774683 --- /dev/null +++ b/static/js/components/ThemeManager.js @@ -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); + }, +}; diff --git a/static/js/components/Visualizer.js b/static/js/components/Visualizer.js new file mode 100644 index 0000000..e55434e --- /dev/null +++ b/static/js/components/Visualizer.js @@ -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(); + } +}; diff --git a/static/js/editor.js b/static/js/editor.js new file mode 100644 index 0000000..5f5aac2 --- /dev/null +++ b/static/js/editor.js @@ -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(); } +}; diff --git a/static/js/ui.js b/static/js/ui.js new file mode 100644 index 0000000..23e0a6f --- /dev/null +++ b/static/js/ui.js @@ -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 = `
${I18nManager.t('label_no_results')}
`; + } + 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); } +}; diff --git a/static/js/utils.js b/static/js/utils.js new file mode 100644 index 0000000..4f170eb --- /dev/null +++ b/static/js/utils.js @@ -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 `${prefix}${id}`; + }); +} + +/** + * HTML 내의 상대 경로 이미지 src를 서버 API 경로(/api/download/...)로 변환합니다. + */ +export function fixImagePaths(html) { + if (!html) return ''; + // src="image.png" 같이 상대 경로로 시작하는 경우만 가로채서 API 경로로 변경 + return html.replace(/ { + return ` (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)); + }); + } +}; diff --git a/static/locales/en.json b/static/locales/en.json new file mode 100644 index 0000000..0d0ec1d --- /dev/null +++ b/static/locales/en.json @@ -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" + } +} diff --git a/static/locales/ko.json b/static/locales/ko.json new file mode 100644 index 0000000..47724c3 --- /dev/null +++ b/static/locales/ko.json @@ -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": "완료모음" + } +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..ad80bc8 --- /dev/null +++ b/static/style.css @@ -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'); diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..b9f8fdd --- /dev/null +++ b/templates/index.html @@ -0,0 +1,219 @@ + + + + + + Brain Dogfood + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ +
+ +
+ Capture knowledge or drop files... +
+ + + +
+ +
+ +
+
+
+
+ + + + + + + + + + + + + + + + + +
+
+

🔍 Knowledge Explorer

+ +
+
+ +
+
+ + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..c249455 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,98 @@ + + + + + + 뇌사료 | 보안 로그인 + + + + + + + + + + + +