feat: 소설 분기 시스템 및 트리 메모장 구현
🌟 주요 기능: - 트리 구조 메모장 시스템 - 소설 분기 관리 (정사 경로 설정) - 중앙 배치 트리 다이어그램 - 정사 경로 목차 뷰 - 인라인 편집 기능 📚 백엔드: - MemoTree, MemoNode 모델 추가 - 정사 경로 자동 순서 관리 - 분기점에서 하나만 선택 가능한 로직 - RESTful API 엔드포인트 🎨 프론트엔드: - memo-tree.html: 트리 다이어그램 에디터 - story-view.html: 정사 경로 목차 뷰 - SVG 연결선으로 시각적 트리 표현 - Alpine.js 기반 반응형 UI - Monaco Editor 통합 ✨ 특별 기능: - 정사 경로 황금색 배지 표시 - 확대/축소 및 패닝 지원 - 드래그 앤 드롭 준비 - 내보내기 및 인쇄 기능 - 인라인 편집 모달
This commit is contained in:
153
backend/database/migrations/005_create_memo_tree_tables.sql
Normal file
153
backend/database/migrations/005_create_memo_tree_tables.sql
Normal file
@@ -0,0 +1,153 @@
|
||||
-- 트리 구조 메모장 테이블 생성
|
||||
-- 005_create_memo_tree_tables.sql
|
||||
|
||||
-- 메모 트리 (프로젝트/워크스페이스)
|
||||
CREATE TABLE memo_trees (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
tree_type VARCHAR(50) DEFAULT 'general', -- 'novel', 'research', 'project', 'general'
|
||||
template_data JSONB, -- 템플릿별 메타데이터
|
||||
settings JSONB DEFAULT '{}', -- 트리별 설정
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_public BOOLEAN DEFAULT FALSE,
|
||||
is_archived BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- 메모 노드 (트리의 각 노드)
|
||||
CREATE TABLE memo_nodes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE,
|
||||
parent_id UUID REFERENCES memo_nodes(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- 기본 정보
|
||||
title VARCHAR(500) NOT NULL,
|
||||
content TEXT, -- 실제 메모 내용 (Markdown)
|
||||
node_type VARCHAR(50) DEFAULT 'memo', -- 'folder', 'memo', 'chapter', 'character', 'plot'
|
||||
|
||||
-- 트리 구조 관리
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
depth_level INTEGER DEFAULT 0,
|
||||
path TEXT, -- 경로 저장 (예: /1/3/7)
|
||||
|
||||
-- 메타데이터
|
||||
tags TEXT[], -- 태그 배열
|
||||
node_metadata JSONB DEFAULT '{}', -- 노드별 메타데이터 (캐릭터 정보, 플롯 정보 등)
|
||||
|
||||
-- 상태 관리
|
||||
status VARCHAR(50) DEFAULT 'draft', -- 'draft', 'writing', 'review', 'complete'
|
||||
word_count INTEGER DEFAULT 0,
|
||||
|
||||
-- 시간 정보
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 제약 조건
|
||||
CONSTRAINT no_self_reference CHECK (id != parent_id)
|
||||
);
|
||||
|
||||
-- 메모 노드 버전 관리 (선택적)
|
||||
CREATE TABLE memo_node_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
node_id UUID NOT NULL REFERENCES memo_nodes(id) ON DELETE CASCADE,
|
||||
version_number INTEGER NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
content TEXT,
|
||||
node_metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
UNIQUE(node_id, version_number)
|
||||
);
|
||||
|
||||
-- 메모 트리 공유 (협업 기능)
|
||||
CREATE TABLE memo_tree_shares (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE,
|
||||
shared_with_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
permission_level VARCHAR(20) DEFAULT 'read', -- 'read', 'write', 'admin'
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
UNIQUE(tree_id, shared_with_user_id)
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX idx_memo_trees_user_id ON memo_trees(user_id);
|
||||
CREATE INDEX idx_memo_trees_type ON memo_trees(tree_type);
|
||||
CREATE INDEX idx_memo_nodes_tree_id ON memo_nodes(tree_id);
|
||||
CREATE INDEX idx_memo_nodes_parent_id ON memo_nodes(parent_id);
|
||||
CREATE INDEX idx_memo_nodes_user_id ON memo_nodes(user_id);
|
||||
CREATE INDEX idx_memo_nodes_path ON memo_nodes USING GIN(string_to_array(path, '/'));
|
||||
CREATE INDEX idx_memo_nodes_tags ON memo_nodes USING GIN(tags);
|
||||
CREATE INDEX idx_memo_nodes_type ON memo_nodes(node_type);
|
||||
CREATE INDEX idx_memo_node_versions_node_id ON memo_node_versions(node_id);
|
||||
CREATE INDEX idx_memo_tree_shares_tree_id ON memo_tree_shares(tree_id);
|
||||
|
||||
-- 트리거 함수: updated_at 자동 업데이트
|
||||
CREATE OR REPLACE FUNCTION update_memo_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 트리거 생성
|
||||
CREATE TRIGGER memo_trees_updated_at
|
||||
BEFORE UPDATE ON memo_trees
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_memo_updated_at();
|
||||
|
||||
CREATE TRIGGER memo_nodes_updated_at
|
||||
BEFORE UPDATE ON memo_nodes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_memo_updated_at();
|
||||
|
||||
-- 트리거 함수: 경로 자동 업데이트
|
||||
CREATE OR REPLACE FUNCTION update_memo_node_path()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- 루트 노드인 경우
|
||||
IF NEW.parent_id IS NULL THEN
|
||||
NEW.path = '/' || NEW.id::text;
|
||||
NEW.depth_level = 0;
|
||||
ELSE
|
||||
-- 부모 노드의 경로를 가져와서 확장
|
||||
SELECT path || '/' || NEW.id::text, depth_level + 1
|
||||
INTO NEW.path, NEW.depth_level
|
||||
FROM memo_nodes
|
||||
WHERE id = NEW.parent_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 경로 업데이트 트리거
|
||||
CREATE TRIGGER memo_nodes_path_update
|
||||
BEFORE INSERT OR UPDATE OF parent_id ON memo_nodes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_memo_node_path();
|
||||
|
||||
-- 샘플 데이터 (개발용)
|
||||
-- 소설 템플릿 예시
|
||||
INSERT INTO memo_trees (user_id, title, description, tree_type, template_data)
|
||||
SELECT
|
||||
u.id,
|
||||
'내 첫 번째 소설',
|
||||
'판타지 소설 프로젝트',
|
||||
'novel',
|
||||
'{
|
||||
"genre": "fantasy",
|
||||
"target_length": 100000,
|
||||
"chapters_planned": 20,
|
||||
"main_characters": [],
|
||||
"world_building": {}
|
||||
}'::jsonb
|
||||
FROM users u
|
||||
WHERE u.email = 'admin@test.com'
|
||||
LIMIT 1;
|
||||
98
backend/database/migrations/006_add_canonical_path.sql
Normal file
98
backend/database/migrations/006_add_canonical_path.sql
Normal file
@@ -0,0 +1,98 @@
|
||||
-- 006_add_canonical_path.sql
|
||||
-- 정사 경로 표시를 위한 필드 추가
|
||||
|
||||
-- memo_nodes 테이블에 정사 경로 관련 필드 추가
|
||||
ALTER TABLE memo_nodes
|
||||
ADD COLUMN is_canonical BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN canonical_order INTEGER DEFAULT NULL,
|
||||
ADD COLUMN story_path TEXT DEFAULT NULL; -- 정사 경로 저장 (예: /1/3/7)
|
||||
|
||||
-- 정사 경로 순서를 위한 인덱스 추가
|
||||
CREATE INDEX idx_memo_nodes_canonical_order ON memo_nodes(tree_id, canonical_order) WHERE is_canonical = TRUE;
|
||||
|
||||
-- 트리별 정사 경로 통계를 위한 뷰 생성
|
||||
CREATE OR REPLACE VIEW memo_tree_canonical_stats AS
|
||||
SELECT
|
||||
t.id as tree_id,
|
||||
t.title as tree_title,
|
||||
COUNT(n.id) as total_nodes,
|
||||
COUNT(CASE WHEN n.is_canonical = TRUE THEN 1 END) as canonical_nodes,
|
||||
MAX(n.canonical_order) as max_canonical_order,
|
||||
STRING_AGG(
|
||||
CASE WHEN n.is_canonical = TRUE THEN n.title END,
|
||||
' → '
|
||||
ORDER BY n.canonical_order
|
||||
) as canonical_story_path
|
||||
FROM memo_trees t
|
||||
LEFT JOIN memo_nodes n ON t.id = n.tree_id
|
||||
GROUP BY t.id, t.title;
|
||||
|
||||
-- 정사 경로 순서 자동 업데이트 함수 (분기점에서 하나만 선택 가능)
|
||||
CREATE OR REPLACE FUNCTION update_canonical_order()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- 정사로 설정될 때
|
||||
IF NEW.is_canonical = TRUE AND (OLD.is_canonical IS NULL OR OLD.is_canonical = FALSE) THEN
|
||||
-- 같은 부모를 가진 다른 형제 노드들의 정사 상태 해제 (분기점에서 하나만 선택)
|
||||
IF NEW.parent_id IS NOT NULL THEN
|
||||
UPDATE memo_nodes
|
||||
SET is_canonical = FALSE, canonical_order = NULL, story_path = NULL
|
||||
WHERE tree_id = NEW.tree_id
|
||||
AND parent_id = NEW.parent_id
|
||||
AND id != NEW.id
|
||||
AND is_canonical = TRUE;
|
||||
END IF;
|
||||
|
||||
-- 부모 노드의 순서를 기준으로 순서 계산
|
||||
IF NEW.parent_id IS NULL THEN
|
||||
-- 루트 노드는 항상 1
|
||||
NEW.canonical_order = 1;
|
||||
ELSE
|
||||
-- 부모 노드의 순서 + 1
|
||||
SELECT COALESCE(parent.canonical_order, 0) + 1
|
||||
INTO NEW.canonical_order
|
||||
FROM memo_nodes parent
|
||||
WHERE parent.id = NEW.parent_id AND parent.is_canonical = TRUE;
|
||||
|
||||
-- 부모가 정사가 아니면 순서 할당 안함
|
||||
IF NEW.canonical_order IS NULL THEN
|
||||
NEW.canonical_order = NULL;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- 정사 경로 업데이트
|
||||
NEW.story_path = COALESCE(NEW.path, '');
|
||||
END IF;
|
||||
|
||||
-- 정사에서 제외될 때 순서 제거
|
||||
IF NEW.is_canonical = FALSE AND OLD.is_canonical = TRUE THEN
|
||||
NEW.canonical_order = NULL;
|
||||
NEW.story_path = NULL;
|
||||
|
||||
-- 뒤의 순서들을 앞으로 당기기
|
||||
UPDATE memo_nodes
|
||||
SET canonical_order = canonical_order - 1
|
||||
WHERE tree_id = NEW.tree_id
|
||||
AND is_canonical = TRUE
|
||||
AND canonical_order > OLD.canonical_order;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 트리거 생성
|
||||
DROP TRIGGER IF EXISTS trigger_update_canonical_order ON memo_nodes;
|
||||
CREATE TRIGGER trigger_update_canonical_order
|
||||
BEFORE UPDATE ON memo_nodes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_canonical_order();
|
||||
|
||||
-- 기존 루트 노드들을 정사로 설정 (기본값)
|
||||
UPDATE memo_nodes
|
||||
SET is_canonical = TRUE, canonical_order = 1
|
||||
WHERE parent_id IS NULL AND is_canonical = FALSE;
|
||||
|
||||
COMMENT ON COLUMN memo_nodes.is_canonical IS '정사 경로 여부 (소설의 메인 스토리라인)';
|
||||
COMMENT ON COLUMN memo_nodes.canonical_order IS '정사 경로에서의 순서 (1부터 시작)';
|
||||
COMMENT ON COLUMN memo_nodes.story_path IS '정사 경로 문자열 표현';
|
||||
97
backend/database/migrations/007_fix_canonical_order.sql
Normal file
97
backend/database/migrations/007_fix_canonical_order.sql
Normal file
@@ -0,0 +1,97 @@
|
||||
-- 007_fix_canonical_order.sql
|
||||
-- 정사 경로 순서 계산 로직 수정
|
||||
|
||||
-- 기존 트리거 삭제
|
||||
DROP TRIGGER IF EXISTS trigger_update_canonical_order ON memo_nodes;
|
||||
DROP FUNCTION IF EXISTS update_canonical_order();
|
||||
|
||||
-- 정사 경로 순서를 올바르게 계산하는 함수
|
||||
CREATE OR REPLACE FUNCTION update_canonical_order()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- 정사로 설정될 때
|
||||
IF NEW.is_canonical = TRUE AND (OLD.is_canonical IS NULL OR OLD.is_canonical = FALSE) THEN
|
||||
-- 같은 부모를 가진 다른 형제 노드들의 정사 상태 해제 (분기점에서 하나만 선택)
|
||||
IF NEW.parent_id IS NOT NULL THEN
|
||||
UPDATE memo_nodes
|
||||
SET is_canonical = FALSE, canonical_order = NULL, story_path = NULL
|
||||
WHERE tree_id = NEW.tree_id
|
||||
AND parent_id = NEW.parent_id
|
||||
AND id != NEW.id
|
||||
AND is_canonical = TRUE;
|
||||
END IF;
|
||||
|
||||
-- 정사 경로 업데이트
|
||||
NEW.story_path = COALESCE(NEW.path, '');
|
||||
|
||||
-- 순서는 별도 함수에서 일괄 계산
|
||||
PERFORM recalculate_canonical_orders(NEW.tree_id);
|
||||
END IF;
|
||||
|
||||
-- 정사에서 제외될 때
|
||||
IF NEW.is_canonical = FALSE AND OLD.is_canonical = TRUE THEN
|
||||
NEW.canonical_order = NULL;
|
||||
NEW.story_path = NULL;
|
||||
|
||||
-- 순서 재계산
|
||||
PERFORM recalculate_canonical_orders(NEW.tree_id);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 트리별 정사 경로 순서를 DFS로 재계산하는 함수
|
||||
CREATE OR REPLACE FUNCTION recalculate_canonical_orders(tree_uuid UUID)
|
||||
RETURNS VOID AS $$
|
||||
DECLARE
|
||||
current_order INTEGER := 1;
|
||||
BEGIN
|
||||
-- 모든 정사 노드의 순서를 NULL로 초기화
|
||||
UPDATE memo_nodes
|
||||
SET canonical_order = NULL
|
||||
WHERE tree_id = tree_uuid AND is_canonical = TRUE;
|
||||
|
||||
-- DFS로 순서 할당 (재귀 CTE 사용)
|
||||
WITH RECURSIVE canonical_path AS (
|
||||
-- 루트 노드들 (정사인 것만)
|
||||
SELECT id, parent_id, title, 1 as order_num, ARRAY[id] as path
|
||||
FROM memo_nodes
|
||||
WHERE tree_id = tree_uuid
|
||||
AND parent_id IS NULL
|
||||
AND is_canonical = TRUE
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 자식 노드들 (정사인 것만)
|
||||
SELECT n.id, n.parent_id, n.title,
|
||||
cp.order_num + 1 as order_num,
|
||||
cp.path || n.id
|
||||
FROM memo_nodes n
|
||||
INNER JOIN canonical_path cp ON n.parent_id = cp.id
|
||||
WHERE n.tree_id = tree_uuid
|
||||
AND n.is_canonical = TRUE
|
||||
)
|
||||
UPDATE memo_nodes
|
||||
SET canonical_order = cp.order_num
|
||||
FROM canonical_path cp
|
||||
WHERE memo_nodes.id = cp.id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 트리거 다시 생성
|
||||
CREATE TRIGGER trigger_update_canonical_order
|
||||
AFTER UPDATE ON memo_nodes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_canonical_order();
|
||||
|
||||
-- 기존 데이터의 순서 재계산
|
||||
DO $$
|
||||
DECLARE
|
||||
tree_rec RECORD;
|
||||
BEGIN
|
||||
FOR tree_rec IN SELECT DISTINCT tree_id FROM memo_nodes WHERE is_canonical = TRUE
|
||||
LOOP
|
||||
PERFORM recalculate_canonical_orders(tree_rec.tree_id);
|
||||
END LOOP;
|
||||
END $$;
|
||||
700
backend/src/api/routes/memo_trees.py
Normal file
700
backend/src/api/routes/memo_trees.py
Normal file
@@ -0,0 +1,700 @@
|
||||
"""
|
||||
트리 구조 메모장 API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, func, and_, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...models.user import User
|
||||
from ...models.memo_tree import MemoTree, MemoNode, MemoNodeVersion, MemoTreeShare
|
||||
from ...schemas.memo_tree import (
|
||||
MemoTreeCreate, MemoTreeUpdate, MemoTreeResponse, MemoTreeWithNodes,
|
||||
MemoNodeCreate, MemoNodeUpdate, MemoNodeResponse, MemoNodeMove,
|
||||
MemoTreeStats, MemoSearchRequest, MemoSearchResult
|
||||
)
|
||||
from ..dependencies import get_current_active_user
|
||||
|
||||
router = APIRouter(prefix="/memo-trees", tags=["memo-trees"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 메모 트리 관리
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/", response_model=List[MemoTreeResponse])
|
||||
async def get_user_memo_trees(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
include_archived: bool = Query(False, description="보관된 트리 포함 여부")
|
||||
):
|
||||
"""사용자의 메모 트리 목록 조회"""
|
||||
try:
|
||||
query = select(MemoTree).where(MemoTree.user_id == current_user.id)
|
||||
|
||||
if not include_archived:
|
||||
query = query.where(MemoTree.is_archived == False)
|
||||
|
||||
query = query.order_by(MemoTree.updated_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
trees = result.scalars().all()
|
||||
|
||||
# 각 트리의 노드 개수 계산
|
||||
tree_responses = []
|
||||
for tree in trees:
|
||||
node_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id)
|
||||
)
|
||||
node_count = node_count_result.scalar() or 0
|
||||
|
||||
tree_dict = {
|
||||
"id": str(tree.id),
|
||||
"user_id": str(tree.user_id),
|
||||
"title": tree.title,
|
||||
"description": tree.description,
|
||||
"tree_type": tree.tree_type,
|
||||
"template_data": tree.template_data,
|
||||
"settings": tree.settings,
|
||||
"created_at": tree.created_at,
|
||||
"updated_at": tree.updated_at,
|
||||
"is_public": tree.is_public,
|
||||
"is_archived": tree.is_archived,
|
||||
"node_count": node_count
|
||||
}
|
||||
tree_responses.append(MemoTreeResponse(**tree_dict))
|
||||
|
||||
return tree_responses
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_user_memo_trees: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get memo trees: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=MemoTreeResponse)
|
||||
async def create_memo_tree(
|
||||
tree_data: MemoTreeCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""새 메모 트리 생성"""
|
||||
try:
|
||||
new_tree = MemoTree(
|
||||
user_id=current_user.id,
|
||||
title=tree_data.title,
|
||||
description=tree_data.description,
|
||||
tree_type=tree_data.tree_type,
|
||||
template_data=tree_data.template_data or {},
|
||||
settings=tree_data.settings or {},
|
||||
is_public=tree_data.is_public
|
||||
)
|
||||
|
||||
db.add(new_tree)
|
||||
await db.commit()
|
||||
await db.refresh(new_tree)
|
||||
|
||||
tree_dict = {
|
||||
"id": str(new_tree.id),
|
||||
"user_id": str(new_tree.user_id),
|
||||
"title": new_tree.title,
|
||||
"description": new_tree.description,
|
||||
"tree_type": new_tree.tree_type,
|
||||
"template_data": new_tree.template_data,
|
||||
"settings": new_tree.settings,
|
||||
"created_at": new_tree.created_at,
|
||||
"updated_at": new_tree.updated_at,
|
||||
"is_public": new_tree.is_public,
|
||||
"is_archived": new_tree.is_archived,
|
||||
"node_count": 0
|
||||
}
|
||||
|
||||
return MemoTreeResponse(**tree_dict)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in create_memo_tree: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create memo tree: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tree_id}", response_model=MemoTreeResponse)
|
||||
async def get_memo_tree(
|
||||
tree_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 트리 상세 조회"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoTree).where(
|
||||
and_(
|
||||
MemoTree.id == tree_id,
|
||||
or_(
|
||||
MemoTree.user_id == current_user.id,
|
||||
MemoTree.is_public == True
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
tree = result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo tree not found"
|
||||
)
|
||||
|
||||
# 노드 개수 계산
|
||||
node_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id)
|
||||
)
|
||||
node_count = node_count_result.scalar() or 0
|
||||
|
||||
tree_dict = {
|
||||
"id": str(tree.id),
|
||||
"user_id": str(tree.user_id),
|
||||
"title": tree.title,
|
||||
"description": tree.description,
|
||||
"tree_type": tree.tree_type,
|
||||
"template_data": tree.template_data,
|
||||
"settings": tree.settings,
|
||||
"created_at": tree.created_at,
|
||||
"updated_at": tree.updated_at,
|
||||
"is_public": tree.is_public,
|
||||
"is_archived": tree.is_archived,
|
||||
"node_count": node_count
|
||||
}
|
||||
|
||||
return MemoTreeResponse(**tree_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_memo_tree: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get memo tree: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{tree_id}", response_model=MemoTreeResponse)
|
||||
async def update_memo_tree(
|
||||
tree_id: UUID,
|
||||
tree_data: MemoTreeUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 트리 업데이트"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoTree).where(
|
||||
and_(
|
||||
MemoTree.id == tree_id,
|
||||
MemoTree.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
)
|
||||
tree = result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo tree not found"
|
||||
)
|
||||
|
||||
# 업데이트할 필드들 적용
|
||||
update_data = tree_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(tree, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(tree)
|
||||
|
||||
# 노드 개수 계산
|
||||
node_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id)
|
||||
)
|
||||
node_count = node_count_result.scalar() or 0
|
||||
|
||||
tree_dict = {
|
||||
"id": str(tree.id),
|
||||
"user_id": str(tree.user_id),
|
||||
"title": tree.title,
|
||||
"description": tree.description,
|
||||
"tree_type": tree.tree_type,
|
||||
"template_data": tree.template_data,
|
||||
"settings": tree.settings,
|
||||
"created_at": tree.created_at,
|
||||
"updated_at": tree.updated_at,
|
||||
"is_public": tree.is_public,
|
||||
"is_archived": tree.is_archived,
|
||||
"node_count": node_count
|
||||
}
|
||||
|
||||
return MemoTreeResponse(**tree_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in update_memo_tree: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update memo tree: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{tree_id}")
|
||||
async def delete_memo_tree(
|
||||
tree_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 트리 삭제"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoTree).where(
|
||||
and_(
|
||||
MemoTree.id == tree_id,
|
||||
MemoTree.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
)
|
||||
tree = result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo tree not found"
|
||||
)
|
||||
|
||||
# 트리 삭제 (CASCADE로 관련 노드들도 자동 삭제됨)
|
||||
await db.delete(tree)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Memo tree deleted successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in delete_memo_tree: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete memo tree: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 메모 노드 관리
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/{tree_id}/nodes", response_model=List[MemoNodeResponse])
|
||||
async def get_memo_tree_nodes(
|
||||
tree_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 트리의 모든 노드 조회"""
|
||||
try:
|
||||
# 트리 접근 권한 확인
|
||||
tree_result = await db.execute(
|
||||
select(MemoTree).where(
|
||||
and_(
|
||||
MemoTree.id == tree_id,
|
||||
or_(
|
||||
MemoTree.user_id == current_user.id,
|
||||
MemoTree.is_public == True
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
tree = tree_result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo tree not found"
|
||||
)
|
||||
|
||||
# 노드들 조회
|
||||
result = await db.execute(
|
||||
select(MemoNode)
|
||||
.where(MemoNode.tree_id == tree_id)
|
||||
.order_by(MemoNode.path, MemoNode.sort_order)
|
||||
)
|
||||
nodes = result.scalars().all()
|
||||
|
||||
# 각 노드의 자식 개수 계산
|
||||
node_responses = []
|
||||
for node in nodes:
|
||||
children_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id)
|
||||
)
|
||||
children_count = children_count_result.scalar() or 0
|
||||
|
||||
node_dict = {
|
||||
"id": str(node.id),
|
||||
"tree_id": str(node.tree_id),
|
||||
"parent_id": str(node.parent_id) if node.parent_id else None,
|
||||
"user_id": str(node.user_id),
|
||||
"title": node.title,
|
||||
"content": node.content,
|
||||
"node_type": node.node_type,
|
||||
"sort_order": node.sort_order,
|
||||
"depth_level": node.depth_level,
|
||||
"path": node.path,
|
||||
"tags": node.tags or [],
|
||||
"node_metadata": node.node_metadata or {},
|
||||
"status": node.status,
|
||||
"word_count": node.word_count,
|
||||
"is_canonical": node.is_canonical,
|
||||
"canonical_order": node.canonical_order,
|
||||
"story_path": node.story_path,
|
||||
"created_at": node.created_at,
|
||||
"updated_at": node.updated_at,
|
||||
"children_count": children_count
|
||||
}
|
||||
node_responses.append(MemoNodeResponse(**node_dict))
|
||||
|
||||
return node_responses
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_memo_tree_nodes: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get memo tree nodes: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{tree_id}/nodes", response_model=MemoNodeResponse)
|
||||
async def create_memo_node(
|
||||
tree_id: UUID,
|
||||
node_data: MemoNodeCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""새 메모 노드 생성"""
|
||||
try:
|
||||
# 트리 접근 권한 확인
|
||||
tree_result = await db.execute(
|
||||
select(MemoTree).where(
|
||||
and_(
|
||||
MemoTree.id == tree_id,
|
||||
MemoTree.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
)
|
||||
tree = tree_result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo tree not found"
|
||||
)
|
||||
|
||||
# 부모 노드 확인 (있다면)
|
||||
if node_data.parent_id:
|
||||
parent_result = await db.execute(
|
||||
select(MemoNode).where(
|
||||
and_(
|
||||
MemoNode.id == UUID(node_data.parent_id),
|
||||
MemoNode.tree_id == tree_id
|
||||
)
|
||||
)
|
||||
)
|
||||
parent_node = parent_result.scalar_one_or_none()
|
||||
if not parent_node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Parent node not found"
|
||||
)
|
||||
|
||||
# 단어 수 계산
|
||||
word_count = 0
|
||||
if node_data.content:
|
||||
word_count = len(node_data.content.replace('\n', ' ').split())
|
||||
|
||||
new_node = MemoNode(
|
||||
tree_id=tree_id,
|
||||
parent_id=UUID(node_data.parent_id) if node_data.parent_id else None,
|
||||
user_id=current_user.id,
|
||||
title=node_data.title,
|
||||
content=node_data.content,
|
||||
node_type=node_data.node_type,
|
||||
sort_order=node_data.sort_order,
|
||||
tags=node_data.tags or [],
|
||||
node_metadata=node_data.node_metadata or {},
|
||||
status=node_data.status,
|
||||
word_count=word_count,
|
||||
is_canonical=node_data.is_canonical or False
|
||||
)
|
||||
|
||||
db.add(new_node)
|
||||
await db.commit()
|
||||
await db.refresh(new_node)
|
||||
|
||||
node_dict = {
|
||||
"id": str(new_node.id),
|
||||
"tree_id": str(new_node.tree_id),
|
||||
"parent_id": str(new_node.parent_id) if new_node.parent_id else None,
|
||||
"user_id": str(new_node.user_id),
|
||||
"title": new_node.title,
|
||||
"content": new_node.content,
|
||||
"node_type": new_node.node_type,
|
||||
"sort_order": new_node.sort_order,
|
||||
"depth_level": new_node.depth_level,
|
||||
"path": new_node.path,
|
||||
"tags": new_node.tags or [],
|
||||
"node_metadata": new_node.node_metadata or {},
|
||||
"status": new_node.status,
|
||||
"word_count": new_node.word_count,
|
||||
"is_canonical": new_node.is_canonical,
|
||||
"canonical_order": new_node.canonical_order,
|
||||
"story_path": new_node.story_path,
|
||||
"created_at": new_node.created_at,
|
||||
"updated_at": new_node.updated_at,
|
||||
"children_count": 0
|
||||
}
|
||||
|
||||
return MemoNodeResponse(**node_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in create_memo_node: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create memo node: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/nodes/{node_id}", response_model=MemoNodeResponse)
|
||||
async def get_memo_node(
|
||||
node_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 노드 상세 조회"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoNode)
|
||||
.options(selectinload(MemoNode.tree))
|
||||
.where(MemoNode.id == node_id)
|
||||
)
|
||||
node = result.scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo node not found"
|
||||
)
|
||||
|
||||
# 접근 권한 확인
|
||||
if node.tree.user_id != current_user.id and not node.tree.is_public:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to access this node"
|
||||
)
|
||||
|
||||
# 자식 개수 계산
|
||||
children_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id)
|
||||
)
|
||||
children_count = children_count_result.scalar() or 0
|
||||
|
||||
node_dict = {
|
||||
"id": str(node.id),
|
||||
"tree_id": str(node.tree_id),
|
||||
"parent_id": str(node.parent_id) if node.parent_id else None,
|
||||
"user_id": str(node.user_id),
|
||||
"title": node.title,
|
||||
"content": node.content,
|
||||
"node_type": node.node_type,
|
||||
"sort_order": node.sort_order,
|
||||
"depth_level": node.depth_level,
|
||||
"path": node.path,
|
||||
"tags": node.tags or [],
|
||||
"node_metadata": node.node_metadata or {},
|
||||
"status": node.status,
|
||||
"word_count": node.word_count,
|
||||
"is_canonical": node.is_canonical,
|
||||
"canonical_order": node.canonical_order,
|
||||
"story_path": node.story_path,
|
||||
"created_at": node.created_at,
|
||||
"updated_at": node.updated_at,
|
||||
"children_count": children_count
|
||||
}
|
||||
|
||||
return MemoNodeResponse(**node_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_memo_node: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get memo node: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/nodes/{node_id}", response_model=MemoNodeResponse)
|
||||
async def update_memo_node(
|
||||
node_id: UUID,
|
||||
node_data: MemoNodeUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 노드 업데이트"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoNode)
|
||||
.options(selectinload(MemoNode.tree))
|
||||
.where(MemoNode.id == node_id)
|
||||
)
|
||||
node = result.scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo node not found"
|
||||
)
|
||||
|
||||
# 접근 권한 확인 (소유자만 수정 가능)
|
||||
if node.tree.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to update this node"
|
||||
)
|
||||
|
||||
# 업데이트할 필드들 적용
|
||||
update_data = node_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if field == "parent_id" and value:
|
||||
# 부모 노드 유효성 검사
|
||||
parent_result = await db.execute(
|
||||
select(MemoNode).where(
|
||||
and_(
|
||||
MemoNode.id == UUID(value),
|
||||
MemoNode.tree_id == node.tree_id
|
||||
)
|
||||
)
|
||||
)
|
||||
parent_node = parent_result.scalar_one_or_none()
|
||||
if not parent_node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Parent node not found"
|
||||
)
|
||||
setattr(node, field, UUID(value))
|
||||
elif field == "parent_id" and value is None:
|
||||
setattr(node, field, None)
|
||||
else:
|
||||
setattr(node, field, value)
|
||||
|
||||
# 내용이 업데이트되면 단어 수 재계산
|
||||
if "content" in update_data:
|
||||
word_count = 0
|
||||
if node.content:
|
||||
word_count = len(node.content.replace('\n', ' ').split())
|
||||
node.word_count = word_count
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(node)
|
||||
|
||||
# 자식 개수 계산
|
||||
children_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id)
|
||||
)
|
||||
children_count = children_count_result.scalar() or 0
|
||||
|
||||
node_dict = {
|
||||
"id": str(node.id),
|
||||
"tree_id": str(node.tree_id),
|
||||
"parent_id": str(node.parent_id) if node.parent_id else None,
|
||||
"user_id": str(node.user_id),
|
||||
"title": node.title,
|
||||
"content": node.content,
|
||||
"node_type": node.node_type,
|
||||
"sort_order": node.sort_order,
|
||||
"depth_level": node.depth_level,
|
||||
"path": node.path,
|
||||
"tags": node.tags or [],
|
||||
"node_metadata": node.node_metadata or {},
|
||||
"status": node.status,
|
||||
"word_count": node.word_count,
|
||||
"is_canonical": node.is_canonical,
|
||||
"canonical_order": node.canonical_order,
|
||||
"story_path": node.story_path,
|
||||
"created_at": node.created_at,
|
||||
"updated_at": node.updated_at,
|
||||
"children_count": children_count
|
||||
}
|
||||
|
||||
return MemoNodeResponse(**node_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in update_memo_node: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update memo node: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/nodes/{node_id}")
|
||||
async def delete_memo_node(
|
||||
node_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 노드 삭제"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoNode)
|
||||
.options(selectinload(MemoNode.tree))
|
||||
.where(MemoNode.id == node_id)
|
||||
)
|
||||
node = result.scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo node not found"
|
||||
)
|
||||
|
||||
# 접근 권한 확인 (소유자만 삭제 가능)
|
||||
if node.tree.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to delete this node"
|
||||
)
|
||||
|
||||
# 노드 삭제 (CASCADE로 자식 노드들도 자동 삭제됨)
|
||||
await db.delete(node)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Memo node deleted successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in delete_memo_node: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete memo node: {str(e)}"
|
||||
)
|
||||
@@ -9,7 +9,7 @@ import uvicorn
|
||||
|
||||
from .core.config import settings
|
||||
from .core.database import init_db
|
||||
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories
|
||||
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -51,6 +51,7 @@ app.include_router(books.router, prefix="/api/books", tags=["서적"])
|
||||
app.include_router(book_categories.router, prefix="/api/book-categories", tags=["서적 소분류"])
|
||||
app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"])
|
||||
app.include_router(search.router, prefix="/api/search", tags=["검색"])
|
||||
app.include_router(memo_trees.router, prefix="/api", tags=["트리 메모장"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
111
backend/src/models/memo_tree.py
Normal file
111
backend/src/models/memo_tree.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
트리 구조 메모장 모델
|
||||
"""
|
||||
from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, ForeignKey, ARRAY, JSON
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class MemoTree(Base):
|
||||
"""메모 트리 (프로젝트/워크스페이스)"""
|
||||
__tablename__ = "memo_trees"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text)
|
||||
tree_type = Column(String(50), default="general") # 'novel', 'research', 'project', 'general'
|
||||
template_data = Column(JSON) # 템플릿별 메타데이터
|
||||
settings = Column(JSON, default={}) # 트리별 설정
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
is_public = Column(Boolean, default=False)
|
||||
is_archived = Column(Boolean, default=False)
|
||||
|
||||
# 관계
|
||||
user = relationship("User", back_populates="memo_trees")
|
||||
nodes = relationship("MemoNode", back_populates="tree", cascade="all, delete-orphan")
|
||||
shares = relationship("MemoTreeShare", back_populates="tree", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class MemoNode(Base):
|
||||
"""메모 노드 (트리의 각 노드)"""
|
||||
__tablename__ = "memo_nodes"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tree_id = Column(UUID(as_uuid=True), ForeignKey("memo_trees.id", ondelete="CASCADE"), nullable=False)
|
||||
parent_id = Column(UUID(as_uuid=True), ForeignKey("memo_nodes.id", ondelete="CASCADE"))
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# 기본 정보
|
||||
title = Column(String(500), nullable=False)
|
||||
content = Column(Text) # Markdown 형식
|
||||
node_type = Column(String(50), default="memo") # 'folder', 'memo', 'chapter', 'character', 'plot'
|
||||
|
||||
# 트리 구조 관리
|
||||
sort_order = Column(Integer, default=0)
|
||||
depth_level = Column(Integer, default=0)
|
||||
path = Column(Text) # 경로 저장 (예: /1/3/7)
|
||||
|
||||
# 메타데이터
|
||||
tags = Column(ARRAY(String)) # 태그 배열
|
||||
node_metadata = Column(JSON, default={}) # 노드별 메타데이터
|
||||
|
||||
# 상태 관리
|
||||
status = Column(String(50), default="draft") # 'draft', 'writing', 'review', 'complete'
|
||||
word_count = Column(Integer, default=0)
|
||||
|
||||
# 정사 경로 관련 필드
|
||||
is_canonical = Column(Boolean, default=False) # 정사 경로 여부
|
||||
canonical_order = Column(Integer, nullable=True) # 정사 경로 순서
|
||||
story_path = Column(Text, nullable=True) # 정사 경로 문자열
|
||||
|
||||
# 시간 정보
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# 관계
|
||||
tree = relationship("MemoTree", back_populates="nodes")
|
||||
user = relationship("User", back_populates="memo_nodes")
|
||||
parent = relationship("MemoNode", remote_side=[id], back_populates="children")
|
||||
children = relationship("MemoNode", back_populates="parent", cascade="all, delete-orphan")
|
||||
versions = relationship("MemoNodeVersion", back_populates="node", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class MemoNodeVersion(Base):
|
||||
"""메모 노드 버전 관리"""
|
||||
__tablename__ = "memo_node_versions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
node_id = Column(UUID(as_uuid=True), ForeignKey("memo_nodes.id", ondelete="CASCADE"), nullable=False)
|
||||
version_number = Column(Integer, nullable=False)
|
||||
title = Column(String(500), nullable=False)
|
||||
content = Column(Text)
|
||||
node_metadata = Column(JSON, default={})
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# 관계
|
||||
node = relationship("MemoNode", back_populates="versions")
|
||||
creator = relationship("User")
|
||||
|
||||
|
||||
class MemoTreeShare(Base):
|
||||
"""메모 트리 공유 (협업 기능)"""
|
||||
__tablename__ = "memo_tree_shares"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tree_id = Column(UUID(as_uuid=True), ForeignKey("memo_trees.id", ondelete="CASCADE"), nullable=False)
|
||||
shared_with_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
permission_level = Column(String(20), default="read") # 'read', 'write', 'admin'
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# 관계
|
||||
tree = relationship("MemoTree", back_populates="shares")
|
||||
shared_with_user = relationship("User", foreign_keys=[shared_with_user_id])
|
||||
creator = relationship("User", foreign_keys=[created_by])
|
||||
@@ -3,6 +3,7 @@
|
||||
"""
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
@@ -30,5 +31,9 @@ class User(Base):
|
||||
language = Column(String(10), default="ko") # ko, en
|
||||
timezone = Column(String(50), default="Asia/Seoul")
|
||||
|
||||
# 관계 (lazy loading을 위해 문자열로 참조)
|
||||
memo_trees = relationship("MemoTree", back_populates="user", lazy="dynamic")
|
||||
memo_nodes = relationship("MemoNode", back_populates="user", lazy="dynamic")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(email='{self.email}', full_name='{self.full_name}')>"
|
||||
|
||||
205
backend/src/schemas/memo_tree.py
Normal file
205
backend/src/schemas/memo_tree.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
트리 구조 메모장 Pydantic 스키마
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
# 기본 스키마들
|
||||
class MemoTreeBase(BaseModel):
|
||||
"""메모 트리 기본 스키마"""
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
tree_type: str = Field(default="general", pattern="^(general|novel|research|project)$")
|
||||
template_data: Optional[Dict[str, Any]] = None
|
||||
settings: Optional[Dict[str, Any]] = None
|
||||
is_public: bool = False
|
||||
|
||||
|
||||
class MemoTreeCreate(MemoTreeBase):
|
||||
"""메모 트리 생성 요청"""
|
||||
pass
|
||||
|
||||
|
||||
class MemoTreeUpdate(BaseModel):
|
||||
"""메모 트리 업데이트 요청"""
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
tree_type: Optional[str] = Field(None, pattern="^(general|novel|research|project)$")
|
||||
template_data: Optional[Dict[str, Any]] = None
|
||||
settings: Optional[Dict[str, Any]] = None
|
||||
is_public: Optional[bool] = None
|
||||
is_archived: Optional[bool] = None
|
||||
|
||||
|
||||
class MemoTreeResponse(MemoTreeBase):
|
||||
"""메모 트리 응답"""
|
||||
id: str
|
||||
user_id: str
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
is_archived: bool
|
||||
node_count: Optional[int] = 0 # 노드 개수
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# 메모 노드 스키마들
|
||||
class MemoNodeBase(BaseModel):
|
||||
"""메모 노드 기본 스키마"""
|
||||
title: str = Field(..., min_length=1, max_length=500)
|
||||
content: Optional[str] = None
|
||||
node_type: str = Field(default="memo", pattern="^(folder|memo|chapter|character|plot)$")
|
||||
tags: Optional[List[str]] = None
|
||||
node_metadata: Optional[Dict[str, Any]] = None
|
||||
status: str = Field(default="draft", pattern="^(draft|writing|review|complete)$")
|
||||
|
||||
# 정사 경로 관련 필드
|
||||
is_canonical: Optional[bool] = False
|
||||
canonical_order: Optional[int] = None
|
||||
|
||||
|
||||
class MemoNodeCreate(MemoNodeBase):
|
||||
"""메모 노드 생성 요청"""
|
||||
tree_id: str
|
||||
parent_id: Optional[str] = None
|
||||
sort_order: Optional[int] = 0
|
||||
|
||||
|
||||
class MemoNodeUpdate(BaseModel):
|
||||
"""메모 노드 업데이트 요청"""
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||
content: Optional[str] = None
|
||||
node_type: Optional[str] = Field(None, pattern="^(folder|memo|chapter|character|plot)$")
|
||||
parent_id: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
tags: Optional[List[str]] = None
|
||||
node_metadata: Optional[Dict[str, Any]] = None
|
||||
status: Optional[str] = Field(None, pattern="^(draft|writing|review|complete)$")
|
||||
|
||||
# 정사 경로 관련 필드
|
||||
is_canonical: Optional[bool] = None
|
||||
canonical_order: Optional[int] = None
|
||||
|
||||
|
||||
class MemoNodeMove(BaseModel):
|
||||
"""메모 노드 이동 요청"""
|
||||
parent_id: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class MemoNodeResponse(MemoNodeBase):
|
||||
"""메모 노드 응답"""
|
||||
id: str
|
||||
tree_id: str
|
||||
parent_id: Optional[str]
|
||||
user_id: str
|
||||
sort_order: int
|
||||
depth_level: int
|
||||
path: Optional[str]
|
||||
word_count: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
# 정사 경로 관련 필드
|
||||
is_canonical: bool
|
||||
canonical_order: Optional[int]
|
||||
story_path: Optional[str]
|
||||
|
||||
# 관계 데이터
|
||||
children_count: Optional[int] = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# 트리 구조 응답
|
||||
class MemoTreeWithNodes(MemoTreeResponse):
|
||||
"""노드가 포함된 메모 트리 응답"""
|
||||
nodes: List[MemoNodeResponse] = []
|
||||
|
||||
|
||||
# 노드 버전 스키마들
|
||||
class MemoNodeVersionResponse(BaseModel):
|
||||
"""메모 노드 버전 응답"""
|
||||
id: str
|
||||
node_id: str
|
||||
version_number: int
|
||||
title: str
|
||||
content: Optional[str]
|
||||
node_metadata: Optional[Dict[str, Any]]
|
||||
created_at: datetime
|
||||
created_by: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# 공유 스키마들
|
||||
class MemoTreeShareCreate(BaseModel):
|
||||
"""메모 트리 공유 생성 요청"""
|
||||
shared_with_user_email: str
|
||||
permission_level: str = Field(default="read", pattern="^(read|write|admin)$")
|
||||
|
||||
|
||||
class MemoTreeShareResponse(BaseModel):
|
||||
"""메모 트리 공유 응답"""
|
||||
id: str
|
||||
tree_id: str
|
||||
shared_with_user_id: str
|
||||
shared_with_user_email: str
|
||||
shared_with_user_name: str
|
||||
permission_level: str
|
||||
created_at: datetime
|
||||
created_by: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# 검색 및 필터링
|
||||
class MemoSearchRequest(BaseModel):
|
||||
"""메모 검색 요청"""
|
||||
query: str = Field(..., min_length=1)
|
||||
tree_id: Optional[str] = None
|
||||
node_types: Optional[List[str]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
status: Optional[List[str]] = None
|
||||
|
||||
|
||||
class MemoSearchResult(BaseModel):
|
||||
"""메모 검색 결과"""
|
||||
node: MemoNodeResponse
|
||||
tree: MemoTreeResponse
|
||||
matches: List[Dict[str, Any]] # 매치된 부분들
|
||||
relevance_score: float
|
||||
|
||||
|
||||
# 통계 스키마
|
||||
class MemoTreeStats(BaseModel):
|
||||
"""메모 트리 통계"""
|
||||
total_nodes: int
|
||||
nodes_by_type: Dict[str, int]
|
||||
nodes_by_status: Dict[str, int]
|
||||
total_words: int
|
||||
last_updated: Optional[datetime]
|
||||
|
||||
|
||||
# 내보내기 스키마
|
||||
class ExportRequest(BaseModel):
|
||||
"""내보내기 요청"""
|
||||
tree_id: str
|
||||
format: str = Field(..., pattern="^(markdown|html|pdf|docx)$")
|
||||
include_metadata: bool = True
|
||||
node_types: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ExportResponse(BaseModel):
|
||||
"""내보내기 응답"""
|
||||
file_url: str
|
||||
file_name: str
|
||||
file_size: int
|
||||
created_at: datetime
|
||||
153
database/init/005_create_memo_tree_tables.sql
Normal file
153
database/init/005_create_memo_tree_tables.sql
Normal file
@@ -0,0 +1,153 @@
|
||||
-- 트리 구조 메모장 테이블 생성
|
||||
-- 005_create_memo_tree_tables.sql
|
||||
|
||||
-- 메모 트리 (프로젝트/워크스페이스)
|
||||
CREATE TABLE memo_trees (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
tree_type VARCHAR(50) DEFAULT 'general', -- 'novel', 'research', 'project', 'general'
|
||||
template_data JSONB, -- 템플릿별 메타데이터
|
||||
settings JSONB DEFAULT '{}', -- 트리별 설정
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_public BOOLEAN DEFAULT FALSE,
|
||||
is_archived BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- 메모 노드 (트리의 각 노드)
|
||||
CREATE TABLE memo_nodes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE,
|
||||
parent_id UUID REFERENCES memo_nodes(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- 기본 정보
|
||||
title VARCHAR(500) NOT NULL,
|
||||
content TEXT, -- 실제 메모 내용 (Markdown)
|
||||
node_type VARCHAR(50) DEFAULT 'memo', -- 'folder', 'memo', 'chapter', 'character', 'plot'
|
||||
|
||||
-- 트리 구조 관리
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
depth_level INTEGER DEFAULT 0,
|
||||
path TEXT, -- 경로 저장 (예: /1/3/7)
|
||||
|
||||
-- 메타데이터
|
||||
tags TEXT[], -- 태그 배열
|
||||
node_metadata JSONB DEFAULT '{}', -- 노드별 메타데이터 (캐릭터 정보, 플롯 정보 등)
|
||||
|
||||
-- 상태 관리
|
||||
status VARCHAR(50) DEFAULT 'draft', -- 'draft', 'writing', 'review', 'complete'
|
||||
word_count INTEGER DEFAULT 0,
|
||||
|
||||
-- 시간 정보
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 제약 조건
|
||||
CONSTRAINT no_self_reference CHECK (id != parent_id)
|
||||
);
|
||||
|
||||
-- 메모 노드 버전 관리 (선택적)
|
||||
CREATE TABLE memo_node_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
node_id UUID NOT NULL REFERENCES memo_nodes(id) ON DELETE CASCADE,
|
||||
version_number INTEGER NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
content TEXT,
|
||||
node_metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
UNIQUE(node_id, version_number)
|
||||
);
|
||||
|
||||
-- 메모 트리 공유 (협업 기능)
|
||||
CREATE TABLE memo_tree_shares (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE,
|
||||
shared_with_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
permission_level VARCHAR(20) DEFAULT 'read', -- 'read', 'write', 'admin'
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
UNIQUE(tree_id, shared_with_user_id)
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX idx_memo_trees_user_id ON memo_trees(user_id);
|
||||
CREATE INDEX idx_memo_trees_type ON memo_trees(tree_type);
|
||||
CREATE INDEX idx_memo_nodes_tree_id ON memo_nodes(tree_id);
|
||||
CREATE INDEX idx_memo_nodes_parent_id ON memo_nodes(parent_id);
|
||||
CREATE INDEX idx_memo_nodes_user_id ON memo_nodes(user_id);
|
||||
CREATE INDEX idx_memo_nodes_path ON memo_nodes USING GIN(string_to_array(path, '/'));
|
||||
CREATE INDEX idx_memo_nodes_tags ON memo_nodes USING GIN(tags);
|
||||
CREATE INDEX idx_memo_nodes_type ON memo_nodes(node_type);
|
||||
CREATE INDEX idx_memo_node_versions_node_id ON memo_node_versions(node_id);
|
||||
CREATE INDEX idx_memo_tree_shares_tree_id ON memo_tree_shares(tree_id);
|
||||
|
||||
-- 트리거 함수: updated_at 자동 업데이트
|
||||
CREATE OR REPLACE FUNCTION update_memo_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 트리거 생성
|
||||
CREATE TRIGGER memo_trees_updated_at
|
||||
BEFORE UPDATE ON memo_trees
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_memo_updated_at();
|
||||
|
||||
CREATE TRIGGER memo_nodes_updated_at
|
||||
BEFORE UPDATE ON memo_nodes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_memo_updated_at();
|
||||
|
||||
-- 트리거 함수: 경로 자동 업데이트
|
||||
CREATE OR REPLACE FUNCTION update_memo_node_path()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- 루트 노드인 경우
|
||||
IF NEW.parent_id IS NULL THEN
|
||||
NEW.path = '/' || NEW.id::text;
|
||||
NEW.depth_level = 0;
|
||||
ELSE
|
||||
-- 부모 노드의 경로를 가져와서 확장
|
||||
SELECT path || '/' || NEW.id::text, depth_level + 1
|
||||
INTO NEW.path, NEW.depth_level
|
||||
FROM memo_nodes
|
||||
WHERE id = NEW.parent_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 경로 업데이트 트리거
|
||||
CREATE TRIGGER memo_nodes_path_update
|
||||
BEFORE INSERT OR UPDATE OF parent_id ON memo_nodes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_memo_node_path();
|
||||
|
||||
-- 샘플 데이터 (개발용)
|
||||
-- 소설 템플릿 예시
|
||||
INSERT INTO memo_trees (user_id, title, description, tree_type, template_data)
|
||||
SELECT
|
||||
u.id,
|
||||
'내 첫 번째 소설',
|
||||
'판타지 소설 프로젝트',
|
||||
'novel',
|
||||
'{
|
||||
"genre": "fantasy",
|
||||
"target_length": 100000,
|
||||
"chapters_planned": 20,
|
||||
"main_characters": [],
|
||||
"world_building": {}
|
||||
}'::jsonb
|
||||
FROM users u
|
||||
WHERE u.email = 'admin@test.com'
|
||||
LIMIT 1;
|
||||
@@ -109,8 +109,9 @@
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900">📚 문서 관리 시스템</h1>
|
||||
<div class="flex space-x-2">
|
||||
<a href="index.html" class="px-3 py-1 text-sm bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200">그리드 뷰</a>
|
||||
<span class="px-3 py-1 text-sm bg-blue-100 text-blue-800 rounded-full">계층구조 뷰</span>
|
||||
<a href="index.html" class="px-3 py-1 text-sm bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200">📖 그리드 뷰</a>
|
||||
<span class="px-3 py-1 text-sm bg-blue-100 text-blue-800 rounded-full">📚 계층구조 뷰</span>
|
||||
<a href="memo-tree.html" class="px-3 py-1 text-sm bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200">🌳 트리 메모장</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -59,8 +59,9 @@
|
||||
Document Server
|
||||
</h1>
|
||||
<div class="flex space-x-2">
|
||||
<span class="px-3 py-1 text-sm bg-blue-100 text-blue-800 rounded-full">그리드 뷰</span>
|
||||
<a href="hierarchy.html" class="px-3 py-1 text-sm bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200">계층구조 뷰</a>
|
||||
<span class="px-3 py-1 text-sm bg-blue-100 text-blue-800 rounded-full">📖 그리드 뷰</span>
|
||||
<a href="hierarchy.html" class="px-3 py-1 text-sm bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200">📚 계층구조 뷰</a>
|
||||
<a href="memo-tree.html" class="px-3 py-1 text-sm bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200">🌳 트리 메모장</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
651
frontend/memo-tree.html
Normal file
651
frontend/memo-tree.html
Normal file
@@ -0,0 +1,651 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>트리 메모장 - Document Server</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Alpine.js 자동 시작 방지 -->
|
||||
<script>
|
||||
// Alpine.js 자동 시작 방지
|
||||
document.addEventListener('alpine:init', () => {
|
||||
console.log('Alpine.js 초기화됨');
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- Monaco Editor (VS Code 에디터) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.44.0/min/vs/loader.js"></script>
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
/* 트리 스타일 */
|
||||
.tree-node {
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tree-node:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.tree-node.selected {
|
||||
background-color: #dbeafe;
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
.tree-node.dragging {
|
||||
opacity: 0.5;
|
||||
transform: rotate(5deg);
|
||||
}
|
||||
|
||||
/* 트리 연결선 */
|
||||
.tree-node-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tree-node-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.tree-node-item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
border-top: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
/* 루트 노드는 연결선 없음 */
|
||||
.tree-node-item.root::before,
|
||||
.tree-node-item.root::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 마지막 자식 노드는 세로선을 중간까지만 */
|
||||
.tree-node-item.last-child::before {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
/* 트리 들여쓰기 */
|
||||
.tree-indent {
|
||||
width: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 트리 라인 스타일 */
|
||||
.tree-line {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tree-line.vertical::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.tree-line.branch::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.tree-line.branch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
border-top: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.tree-line.corner::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
height: 50%;
|
||||
border-left: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.tree-line.corner::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
border-top: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.tree-line.empty {
|
||||
/* 빈 공간 */
|
||||
}
|
||||
|
||||
/* 에디터 컨테이너 */
|
||||
.editor-container {
|
||||
height: calc(100vh - 200px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* 스플리터 */
|
||||
.splitter {
|
||||
width: 4px;
|
||||
background: #e5e7eb;
|
||||
cursor: col-resize;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.splitter:hover {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
/* 노드 타입별 아이콘 색상 */
|
||||
.node-folder { color: #f59e0b; }
|
||||
.node-memo { color: #6b7280; }
|
||||
.node-chapter { color: #8b5cf6; }
|
||||
.node-character { color: #10b981; }
|
||||
.node-plot { color: #ef4444; }
|
||||
|
||||
/* 상태별 색상 */
|
||||
.status-draft { border-left-color: #9ca3af; }
|
||||
.status-writing { border-left-color: #f59e0b; }
|
||||
.status-review { border-left-color: #3b82f6; }
|
||||
.status-complete { border-left-color: #10b981; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-50" x-data="memoTreeApp()" x-init="init()" x-cloak>
|
||||
<!-- 헤더 -->
|
||||
<header class="bg-white shadow-sm border-b">
|
||||
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- 로고 및 네비게이션 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i class="fas fa-sitemap text-blue-600 text-xl"></i>
|
||||
<h1 class="text-xl font-bold text-gray-900">트리 메모장</h1>
|
||||
</div>
|
||||
|
||||
<!-- 네비게이션 링크 -->
|
||||
<div class="hidden md:flex space-x-4">
|
||||
<a href="index.html" class="text-gray-600 hover:text-gray-900">📖 문서 관리</a>
|
||||
<a href="hierarchy.html" class="text-gray-600 hover:text-gray-900">📚 계층구조</a>
|
||||
<span class="text-blue-600 font-medium">🌳 트리 메모장</span>
|
||||
<a href="story-view.html" class="text-gray-600 hover:text-gray-900">📖 스토리 뷰</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 메뉴 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 트리 관리 버튼들 -->
|
||||
<div x-show="currentUser" class="flex space-x-2">
|
||||
<button
|
||||
@click="showNewTreeModal = true"
|
||||
class="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
title="새 트리 생성"
|
||||
>
|
||||
<i class="fas fa-plus mr-1"></i> 새 트리
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="showTreeSettings = !showTreeSettings"
|
||||
x-show="selectedTree"
|
||||
class="px-3 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
title="트리 설정"
|
||||
>
|
||||
<i class="fas fa-cog"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 정보 -->
|
||||
<div x-show="currentUser" class="flex items-center space-x-3">
|
||||
<span class="text-sm text-gray-600" x-text="currentUser?.full_name || currentUser?.email"></span>
|
||||
<button @click="logout()" class="text-sm text-red-600 hover:text-red-800">로그아웃</button>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 버튼 -->
|
||||
<button x-show="!currentUser" @click="openLoginModal()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
로그인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="h-screen pt-16" x-show="currentUser">
|
||||
<!-- 상단 툴바 -->
|
||||
<div class="bg-white border-b shadow-sm p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- 트리 선택 -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<select
|
||||
x-model="selectedTreeId"
|
||||
@change="loadTree(selectedTreeId)"
|
||||
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="">📂 트리 선택</option>
|
||||
<template x-for="tree in userTrees" :key="tree.id">
|
||||
<option :value="tree.id" x-text="`${getTreeIcon(tree.tree_type)} ${tree.title}`"></option>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<button
|
||||
@click="showNewTreeModal = true"
|
||||
class="px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm"
|
||||
title="새 트리"
|
||||
>
|
||||
<i class="fas fa-plus mr-1"></i> 새 트리
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 트리 액션 버튼들 -->
|
||||
<div x-show="selectedTree" class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="createRootNode()"
|
||||
class="px-3 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
|
||||
>
|
||||
<i class="fas fa-plus mr-1"></i> 노드 추가
|
||||
</button>
|
||||
<button
|
||||
@click="centerTree()"
|
||||
class="px-3 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 text-sm"
|
||||
title="트리 중앙 정렬"
|
||||
>
|
||||
<i class="fas fa-crosshairs"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="zoomIn()"
|
||||
class="px-2 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 text-sm"
|
||||
title="확대"
|
||||
>
|
||||
<i class="fas fa-search-plus"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="zoomOut()"
|
||||
class="px-2 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 text-sm"
|
||||
title="축소"
|
||||
>
|
||||
<i class="fas fa-search-minus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 트리 다이어그램 영역 -->
|
||||
<div class="flex h-full">
|
||||
<!-- 트리 다이어그램 (중앙) -->
|
||||
<div class="flex-1 relative overflow-hidden bg-gray-50">
|
||||
<!-- 트리 없음 상태 -->
|
||||
<div x-show="!selectedTree" class="flex items-center justify-center h-full">
|
||||
<div class="text-center text-gray-500">
|
||||
<i class="fas fa-sitemap text-6xl text-gray-300 mb-4"></i>
|
||||
<h3 class="text-lg font-medium mb-2">트리를 선택하세요</h3>
|
||||
<p class="text-sm">왼쪽에서 트리를 선택하거나 새로 만들어보세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노드 없음 상태 -->
|
||||
<div x-show="selectedTree && treeNodes.length === 0" class="flex items-center justify-center h-full">
|
||||
<div class="text-center text-gray-500">
|
||||
<i class="fas fa-plus-circle text-6xl text-gray-300 mb-4"></i>
|
||||
<h3 class="text-lg font-medium mb-2">첫 번째 노드를 만들어보세요</h3>
|
||||
<button
|
||||
@click="createRootNode()"
|
||||
class="mt-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
<i class="fas fa-plus mr-2"></i> 루트 노드 생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 트리 다이어그램 캔버스 -->
|
||||
<div x-show="selectedTree && treeNodes.length > 0" class="relative w-full h-full" id="tree-canvas">
|
||||
<!-- 전체 트리 컨테이너 (SVG + 노드 함께 스케일링) -->
|
||||
<div
|
||||
class="absolute inset-0 w-full h-full"
|
||||
id="tree-container"
|
||||
:style="`transform: scale(${treeZoom}) translate(${treePanX}px, ${treePanY}px); transform-origin: center center;`"
|
||||
@mousedown="startPan($event)"
|
||||
@wheel="handleWheel($event)"
|
||||
>
|
||||
<!-- SVG 연결선 레이어 -->
|
||||
<svg class="absolute inset-0 w-full h-full pointer-events-none z-10" id="tree-connections">
|
||||
<!-- 연결선들이 여기에 그려짐 -->
|
||||
</svg>
|
||||
|
||||
<!-- 노드 레이어 -->
|
||||
<div class="absolute inset-0 w-full h-full z-20" id="tree-nodes">
|
||||
<!-- 노드들이 여기에 배치됨 -->
|
||||
<template x-for="node in treeNodes" :key="node.id">
|
||||
<div
|
||||
class="absolute tree-diagram-node"
|
||||
:style="getNodePosition(node)"
|
||||
@mousedown="startDragNode($event, node)"
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-md border-2 p-3 cursor-pointer min-w-32 max-w-48 relative"
|
||||
:class="{
|
||||
'border-blue-500 shadow-lg': selectedNode && selectedNode.id === node.id,
|
||||
'border-gray-200': !selectedNode || selectedNode.id !== node.id,
|
||||
'border-gray-400': node.status === 'draft',
|
||||
'border-yellow-400': node.status === 'writing',
|
||||
'border-blue-400': node.status === 'review',
|
||||
'border-green-400': node.status === 'complete',
|
||||
'bg-gradient-to-br from-yellow-50 to-amber-50 border-amber-400': node.is_canonical,
|
||||
'shadow-amber-200': node.is_canonical
|
||||
}"
|
||||
@click="selectNode(node)"
|
||||
@dblclick="editNodeInline(node)"
|
||||
>
|
||||
<!-- 정사 경로 배지 -->
|
||||
<div x-show="node.is_canonical" class="absolute -top-2 -right-2 bg-amber-500 text-white text-xs px-2 py-1 rounded-full font-bold shadow-md">
|
||||
<i class="fas fa-star mr-1"></i><span x-text="node.canonical_order || '?'"></span>
|
||||
</div>
|
||||
|
||||
<!-- 노드 헤더 -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<span class="text-lg mr-1" x-text="getNodeIcon(node.node_type)"></span>
|
||||
<span x-show="node.is_canonical" class="text-amber-600 text-sm" title="정사 경로">⭐</span>
|
||||
</div>
|
||||
<div class="flex space-x-1">
|
||||
<button
|
||||
@click.stop="toggleCanonical(node)"
|
||||
class="w-5 h-5 flex items-center justify-center rounded"
|
||||
:class="node.is_canonical ? 'text-amber-600 hover:text-amber-700 hover:bg-amber-50' : 'text-gray-400 hover:text-amber-600 hover:bg-amber-50'"
|
||||
:title="node.is_canonical ? '정사에서 제외' : '정사로 설정'"
|
||||
>
|
||||
<i class="fas fa-star text-xs"></i>
|
||||
</button>
|
||||
<button
|
||||
@click.stop="addChildNode(node)"
|
||||
class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-green-600 hover:bg-green-50 rounded"
|
||||
title="자식 노드 추가"
|
||||
>
|
||||
<i class="fas fa-plus text-xs"></i>
|
||||
</button>
|
||||
<button
|
||||
@click.stop="showNodeMenu($event, node)"
|
||||
class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded"
|
||||
title="더보기"
|
||||
>
|
||||
<i class="fas fa-ellipsis-h text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노드 제목 -->
|
||||
<div class="text-sm font-medium text-gray-800 truncate" x-text="node.title"></div>
|
||||
|
||||
<!-- 노드 정보 -->
|
||||
<div class="flex items-center justify-between mt-2 text-xs text-gray-500">
|
||||
<span x-text="node.node_type"></span>
|
||||
<span x-show="node.word_count > 0" x-text="`${node.word_count}w`"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 속성 패널 -->
|
||||
<div class="w-80 bg-white border-l shadow-lg flex flex-col">
|
||||
<!-- 에디터 헤더 -->
|
||||
<template x-if="selectedNode">
|
||||
<div class="p-4 border-b bg-white">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
x-model="selectedNode.title"
|
||||
@blur="saveNodeTitle()"
|
||||
class="text-lg font-semibold bg-transparent border-none focus:outline-none focus:ring-2 focus:ring-blue-500 rounded px-2 py-1 w-full"
|
||||
placeholder="노드 제목을 입력하세요"
|
||||
>
|
||||
<div class="flex items-center space-x-4 mt-2 text-sm text-gray-600">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span>타입:</span>
|
||||
<select
|
||||
x-model="selectedNode.node_type"
|
||||
@change="saveNodeType()"
|
||||
class="border border-gray-300 rounded px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="memo">📝 메모</option>
|
||||
<option value="folder">📁 폴더</option>
|
||||
<option value="chapter">📖 챕터</option>
|
||||
<option value="character">👤 캐릭터</option>
|
||||
<option value="plot">📋 플롯</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span>상태:</span>
|
||||
<select
|
||||
x-model="selectedNode.status"
|
||||
@change="saveNodeStatus()"
|
||||
class="border border-gray-300 rounded px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="draft">📝 초안</option>
|
||||
<option value="writing">✍️ 작성중</option>
|
||||
<option value="review">👀 검토중</option>
|
||||
<option value="complete">✅ 완료</option>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="selectedNode?.word_count > 0" class="text-xs">
|
||||
<span x-text="`${selectedNode?.word_count || 0} 단어`"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="saveNode()"
|
||||
class="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
|
||||
>
|
||||
<i class="fas fa-save mr-1"></i> 저장
|
||||
</button>
|
||||
<button
|
||||
@click="deleteNode(selectedNode.id)"
|
||||
class="px-3 py-1 bg-red-600 text-white rounded text-sm hover:bg-red-700"
|
||||
>
|
||||
<i class="fas fa-trash mr-1"></i> 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 에디터 -->
|
||||
<div x-show="selectedNode" class="flex-1 editor-container">
|
||||
<div id="monaco-editor" class="h-full"></div>
|
||||
</div>
|
||||
|
||||
<!-- 노드 미선택 상태 -->
|
||||
<div x-show="!selectedNode" class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center text-gray-500">
|
||||
<i class="fas fa-edit text-6xl text-gray-300 mb-4"></i>
|
||||
<h3 class="text-lg font-medium mb-2">노드를 선택하세요</h3>
|
||||
<p class="text-sm">왼쪽 트리에서 노드를 클릭하여 편집을 시작하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그인이 필요한 상태 -->
|
||||
<div x-show="!currentUser" class="flex items-center justify-center h-screen">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-lock text-6xl text-gray-300 mb-4"></i>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">로그인이 필요합니다</h2>
|
||||
<p class="text-gray-600 mb-4">트리 메모장을 사용하려면 로그인해주세요</p>
|
||||
<button @click="openLoginModal()" class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
로그인하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 새 트리 생성 모달 -->
|
||||
<div x-show="showNewTreeModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
|
||||
<h2 class="text-xl font-bold mb-4">새 트리 생성</h2>
|
||||
<form @submit.prevent="createTree()">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">제목</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="newTree.title"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="트리 제목을 입력하세요"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
|
||||
<textarea
|
||||
x-model="newTree.description"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="트리에 대한 설명을 입력하세요"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">타입</label>
|
||||
<select
|
||||
x-model="newTree.tree_type"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="general">📝 일반</option>
|
||||
<option value="novel">📚 소설</option>
|
||||
<option value="research">🔬 연구</option>
|
||||
<option value="project">💼 프로젝트</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
@click="showNewTreeModal = false"
|
||||
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
생성
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 모달 -->
|
||||
<div x-show="showLoginModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
|
||||
<h2 class="text-xl font-bold mb-4">로그인</h2>
|
||||
<form @submit.prevent="handleLogin()">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">이메일</label>
|
||||
<input
|
||||
type="email"
|
||||
x-model="loginForm.email"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
x-model="loginForm.password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
</div>
|
||||
<div x-show="loginError" class="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||
<span x-text="loginError"></span>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="showLoginModal = false"
|
||||
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loginLoading"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!loginLoading">로그인</span>
|
||||
<span x-show="loginLoading">로그인 중...</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script>
|
||||
// 스크립트 순차 로딩을 위한 함수
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// 스크립트들을 순차적으로 로드
|
||||
async function loadScripts() {
|
||||
try {
|
||||
await loadScript('static/js/api.js?v=2025012290');
|
||||
console.log('✅ API 스크립트 로드 완료');
|
||||
await loadScript('static/js/auth.js?v=2025012240');
|
||||
console.log('✅ Auth 스크립트 로드 완료');
|
||||
await loadScript('static/js/memo-tree.js?v=2025012280');
|
||||
console.log('✅ Memo Tree 스크립트 로드 완료');
|
||||
|
||||
// 모든 스크립트 로드 완료 후 Alpine.js 로드
|
||||
console.log('🚀 Alpine.js 로딩...');
|
||||
await loadScript('https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js');
|
||||
console.log('✅ Alpine.js 로드 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 스크립트 로딩 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// DOM 로드 후 스크립트 로딩 시작
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', loadScripts);
|
||||
} else {
|
||||
loadScripts();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -119,7 +119,32 @@ class DocumentServerAPI {
|
||||
|
||||
// 인증 관련 API
|
||||
async login(email, password) {
|
||||
return await this.post('/auth/login', { email, password });
|
||||
const response = await this.post('/auth/login', { email, password });
|
||||
|
||||
// 토큰 저장
|
||||
if (response.access_token) {
|
||||
this.setToken(response.access_token);
|
||||
|
||||
// 사용자 정보 가져오기
|
||||
try {
|
||||
const user = await this.getCurrentUser();
|
||||
return {
|
||||
success: true,
|
||||
user: user,
|
||||
token: response.access_token
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: '사용자 정보를 가져올 수 없습니다.'
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: '로그인에 실패했습니다.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
@@ -414,6 +439,73 @@ class DocumentServerAPI {
|
||||
async deleteNote(noteId) {
|
||||
return await this.delete(`/notes/${noteId}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 트리 메모장 API
|
||||
// ============================================================================
|
||||
|
||||
// 메모 트리 관리
|
||||
async getUserMemoTrees(includeArchived = false) {
|
||||
const params = includeArchived ? '?include_archived=true' : '';
|
||||
return await this.get(`/memo-trees/${params}`);
|
||||
}
|
||||
|
||||
async createMemoTree(treeData) {
|
||||
return await this.post('/memo-trees/', treeData);
|
||||
}
|
||||
|
||||
async getMemoTree(treeId) {
|
||||
return await this.get(`/memo-trees/${treeId}`);
|
||||
}
|
||||
|
||||
async updateMemoTree(treeId, treeData) {
|
||||
return await this.put(`/memo-trees/${treeId}`, treeData);
|
||||
}
|
||||
|
||||
async deleteMemoTree(treeId) {
|
||||
return await this.delete(`/memo-trees/${treeId}`);
|
||||
}
|
||||
|
||||
// 메모 노드 관리
|
||||
async getMemoTreeNodes(treeId) {
|
||||
return await this.get(`/memo-trees/${treeId}/nodes`);
|
||||
}
|
||||
|
||||
async createMemoNode(nodeData) {
|
||||
return await this.post(`/memo-trees/${nodeData.tree_id}/nodes`, nodeData);
|
||||
}
|
||||
|
||||
async getMemoNode(nodeId) {
|
||||
return await this.get(`/memo-trees/nodes/${nodeId}`);
|
||||
}
|
||||
|
||||
async updateMemoNode(nodeId, nodeData) {
|
||||
return await this.put(`/memo-trees/nodes/${nodeId}`, nodeData);
|
||||
}
|
||||
|
||||
async deleteMemoNode(nodeId) {
|
||||
return await this.delete(`/memo-trees/nodes/${nodeId}`);
|
||||
}
|
||||
|
||||
// 노드 이동
|
||||
async moveMemoNode(nodeId, moveData) {
|
||||
return await this.put(`/memo-trees/nodes/${nodeId}/move`, moveData);
|
||||
}
|
||||
|
||||
// 트리 통계
|
||||
async getMemoTreeStats(treeId) {
|
||||
return await this.get(`/memo-trees/${treeId}/stats`);
|
||||
}
|
||||
|
||||
// 검색
|
||||
async searchMemoNodes(searchData) {
|
||||
return await this.post('/memo-trees/search', searchData);
|
||||
}
|
||||
|
||||
// 내보내기
|
||||
async exportMemoTree(exportData) {
|
||||
return await this.post('/memo-trees/export', exportData);
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 API 인스턴스
|
||||
|
||||
984
frontend/static/js/memo-tree.js
Normal file
984
frontend/static/js/memo-tree.js
Normal file
@@ -0,0 +1,984 @@
|
||||
/**
|
||||
* 트리 구조 메모장 JavaScript
|
||||
*/
|
||||
|
||||
// Monaco Editor 인스턴스
|
||||
let monacoEditor = null;
|
||||
|
||||
// 트리 메모장 Alpine.js 컴포넌트
|
||||
window.memoTreeApp = function() {
|
||||
return {
|
||||
// 상태 관리
|
||||
currentUser: null,
|
||||
userTrees: [],
|
||||
selectedTreeId: '',
|
||||
selectedTree: null,
|
||||
treeNodes: [],
|
||||
selectedNode: null,
|
||||
|
||||
// UI 상태
|
||||
showNewTreeModal: false,
|
||||
showNewNodeModal: false,
|
||||
showTreeSettings: false,
|
||||
showLoginModal: false,
|
||||
|
||||
// 로그인 폼 상태
|
||||
loginForm: {
|
||||
email: '',
|
||||
password: ''
|
||||
},
|
||||
loginError: '',
|
||||
loginLoading: false,
|
||||
|
||||
// 폼 데이터
|
||||
newTree: {
|
||||
title: '',
|
||||
description: '',
|
||||
tree_type: 'general'
|
||||
},
|
||||
|
||||
newNode: {
|
||||
title: '',
|
||||
node_type: 'memo',
|
||||
parent_id: null
|
||||
},
|
||||
|
||||
// 에디터 상태
|
||||
editorContent: '',
|
||||
isEditorDirty: false,
|
||||
|
||||
// 트리 상태
|
||||
expandedNodes: new Set(),
|
||||
|
||||
// 트리 다이어그램 상태
|
||||
treeZoom: 1,
|
||||
treePanX: 0,
|
||||
treePanY: 0,
|
||||
nodePositions: new Map(), // 노드 ID -> {x, y} 위치 매핑
|
||||
isDragging: false,
|
||||
dragNode: null,
|
||||
dragOffset: { x: 0, y: 0 },
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('🌳 트리 메모장 초기화 중...');
|
||||
|
||||
// API 객체가 로드될 때까지 대기 (더 긴 시간)
|
||||
let retries = 0;
|
||||
while ((!window.api || typeof window.api.getUserMemoTrees !== 'function') && retries < 50) {
|
||||
console.log(`⏳ API 객체 로딩 대기 중... (${retries + 1}/50)`);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
retries++;
|
||||
}
|
||||
|
||||
if (!window.api || typeof window.api.getUserMemoTrees !== 'function') {
|
||||
console.error('❌ API 객체 또는 getUserMemoTrees 함수를 로드할 수 없습니다.');
|
||||
console.log('현재 window.api:', window.api);
|
||||
if (window.api) {
|
||||
console.log('API 객체의 메서드들:', Object.getOwnPropertyNames(window.api));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.checkAuthStatus();
|
||||
if (this.currentUser) {
|
||||
await this.loadUserTrees();
|
||||
await this.initMonacoEditor();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 초기화 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const user = await window.api.getCurrentUser();
|
||||
this.currentUser = user;
|
||||
console.log('✅ 사용자 인증됨:', user.email);
|
||||
} catch (error) {
|
||||
console.log('❌ 인증되지 않음:', error.message);
|
||||
this.currentUser = null;
|
||||
|
||||
// 토큰이 있지만 만료된 경우 제거
|
||||
if (localStorage.getItem('access_token')) {
|
||||
console.log('🗑️ 만료된 토큰 제거');
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.api.clearToken();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 사용자 트리 목록 로드
|
||||
async loadUserTrees() {
|
||||
try {
|
||||
console.log('📊 사용자 트리 목록 로딩...');
|
||||
const trees = await window.api.getUserMemoTrees();
|
||||
this.userTrees = trees || [];
|
||||
console.log(`✅ ${this.userTrees.length}개 트리 로드 완료`);
|
||||
} catch (error) {
|
||||
console.error('❌ 트리 목록 로드 실패:', error);
|
||||
this.userTrees = [];
|
||||
}
|
||||
},
|
||||
|
||||
// 트리 로드
|
||||
async loadTree(treeId) {
|
||||
if (!treeId) {
|
||||
this.selectedTree = null;
|
||||
this.treeNodes = [];
|
||||
this.selectedNode = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🌳 트리 로딩:', treeId);
|
||||
|
||||
// 트리 정보 로드
|
||||
const tree = this.userTrees.find(t => t.id === treeId);
|
||||
this.selectedTree = tree;
|
||||
|
||||
// 트리 노드들 로드
|
||||
const nodes = await window.api.getMemoTreeNodes(treeId);
|
||||
this.treeNodes = nodes || [];
|
||||
|
||||
// 첫 번째 노드 선택 (있다면)
|
||||
if (this.treeNodes.length > 0) {
|
||||
this.selectNode(this.treeNodes[0]);
|
||||
}
|
||||
|
||||
// 트리 다이어그램 위치 계산
|
||||
this.$nextTick(() => {
|
||||
setTimeout(() => {
|
||||
this.calculateNodePositions();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
console.log(`✅ 트리 로드 완료: ${this.treeNodes.length}개 노드`);
|
||||
} catch (error) {
|
||||
console.error('❌ 트리 로드 실패:', error);
|
||||
alert('트리를 불러오는 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
// 트리 생성
|
||||
async createTree() {
|
||||
try {
|
||||
console.log('🌳 새 트리 생성:', this.newTree);
|
||||
|
||||
const tree = await window.api.createMemoTree(this.newTree);
|
||||
|
||||
// 트리 목록에 추가
|
||||
this.userTrees.push(tree);
|
||||
|
||||
// 새 트리 선택
|
||||
this.selectedTreeId = tree.id;
|
||||
await this.loadTree(tree.id);
|
||||
|
||||
// 모달 닫기 및 폼 리셋
|
||||
this.showNewTreeModal = false;
|
||||
this.newTree = { title: '', description: '', tree_type: 'general' };
|
||||
|
||||
console.log('✅ 트리 생성 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 트리 생성 실패:', error);
|
||||
alert('트리 생성 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
// 루트 노드 생성
|
||||
async createRootNode() {
|
||||
if (!this.selectedTree) return;
|
||||
|
||||
try {
|
||||
const nodeData = {
|
||||
tree_id: this.selectedTree.id,
|
||||
title: '새 노드',
|
||||
node_type: 'memo',
|
||||
parent_id: null
|
||||
};
|
||||
|
||||
const node = await window.api.createMemoNode(nodeData);
|
||||
this.treeNodes.push(node);
|
||||
this.selectNode(node);
|
||||
|
||||
console.log('✅ 루트 노드 생성 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 루트 노드 생성 실패:', error);
|
||||
alert('노드 생성 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
// 노드 선택
|
||||
selectNode(node) {
|
||||
// 이전 노드 저장
|
||||
if (this.selectedNode && this.isEditorDirty) {
|
||||
this.saveNode();
|
||||
}
|
||||
|
||||
this.selectedNode = node;
|
||||
|
||||
// 에디터에 내용 로드
|
||||
if (monacoEditor && node.content) {
|
||||
monacoEditor.setValue(node.content || '');
|
||||
this.isEditorDirty = false;
|
||||
}
|
||||
|
||||
console.log('📝 노드 선택:', node.title);
|
||||
},
|
||||
|
||||
// 노드 저장
|
||||
async saveNode() {
|
||||
if (!this.selectedNode) return;
|
||||
|
||||
try {
|
||||
// 에디터 내용 가져오기
|
||||
if (monacoEditor) {
|
||||
this.selectedNode.content = monacoEditor.getValue();
|
||||
|
||||
// 단어 수 계산 (간단한 방식)
|
||||
const wordCount = this.selectedNode.content
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.split(' ')
|
||||
.filter(word => word.length > 0).length;
|
||||
this.selectedNode.word_count = wordCount;
|
||||
}
|
||||
|
||||
await window.api.updateMemoNode(this.selectedNode.id, this.selectedNode);
|
||||
this.isEditorDirty = false;
|
||||
|
||||
console.log('✅ 노드 저장 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 노드 저장 실패:', error);
|
||||
alert('노드 저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
// 노드 제목 저장
|
||||
async saveNodeTitle() {
|
||||
if (!this.selectedNode) return;
|
||||
await this.saveNode();
|
||||
},
|
||||
|
||||
// 노드 타입 저장
|
||||
async saveNodeType() {
|
||||
if (!this.selectedNode) return;
|
||||
await this.saveNode();
|
||||
},
|
||||
|
||||
// 노드 상태 저장
|
||||
async saveNodeStatus() {
|
||||
if (!this.selectedNode) return;
|
||||
await this.saveNode();
|
||||
},
|
||||
|
||||
// 노드 삭제
|
||||
async deleteNode(nodeId) {
|
||||
if (!confirm('정말로 이 노드를 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
await window.api.deleteMemoNode(nodeId);
|
||||
|
||||
// 트리에서 제거
|
||||
this.treeNodes = this.treeNodes.filter(node => node.id !== nodeId);
|
||||
|
||||
// 선택된 노드였다면 선택 해제
|
||||
if (this.selectedNode && this.selectedNode.id === nodeId) {
|
||||
this.selectedNode = null;
|
||||
if (monacoEditor) {
|
||||
monacoEditor.setValue('');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ 노드 삭제 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 노드 삭제 실패:', error);
|
||||
alert('노드 삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
// 자식 노드 가져오기
|
||||
getChildNodes(parentId) {
|
||||
return this.treeNodes
|
||||
.filter(node => node.parent_id === parentId)
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
},
|
||||
|
||||
// 루트 노드들 가져오기
|
||||
get rootNodes() {
|
||||
return this.treeNodes
|
||||
.filter(node => !node.parent_id)
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
},
|
||||
|
||||
// 노드 타입별 아이콘 가져오기
|
||||
getNodeIcon(nodeType) {
|
||||
const icons = {
|
||||
folder: '📁',
|
||||
memo: '📝',
|
||||
chapter: '📖',
|
||||
character: '👤',
|
||||
plot: '📋'
|
||||
};
|
||||
return icons[nodeType] || '📝';
|
||||
},
|
||||
|
||||
// 상태별 색상 클래스 가져오기
|
||||
getStatusColor(status) {
|
||||
const colors = {
|
||||
draft: 'text-gray-500',
|
||||
writing: 'text-yellow-600',
|
||||
review: 'text-blue-600',
|
||||
complete: 'text-green-600'
|
||||
};
|
||||
return colors[status] || 'text-gray-700';
|
||||
},
|
||||
|
||||
// 트리 타입별 아이콘 가져오기
|
||||
getTreeIcon(treeType) {
|
||||
const icons = {
|
||||
novel: '📚',
|
||||
research: '🔬',
|
||||
project: '💼',
|
||||
general: '📂'
|
||||
};
|
||||
return icons[treeType] || '📂';
|
||||
},
|
||||
|
||||
// 빠른 자식 노드 추가
|
||||
async addChildNode(parentNode) {
|
||||
if (!this.selectedTree) return;
|
||||
|
||||
try {
|
||||
const nodeData = {
|
||||
tree_id: this.selectedTree.id,
|
||||
title: '새 노드',
|
||||
node_type: 'memo',
|
||||
parent_id: parentNode.id
|
||||
};
|
||||
|
||||
const node = await window.api.createMemoNode(nodeData);
|
||||
this.treeNodes.push(node);
|
||||
|
||||
// 부모 노드 펼치기
|
||||
this.expandedNodes.add(parentNode.id);
|
||||
|
||||
// 새 노드 선택
|
||||
this.selectNode(node);
|
||||
|
||||
console.log('✅ 자식 노드 생성 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 자식 노드 생성 실패:', error);
|
||||
alert('노드 생성 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
// 컨텍스트 메뉴 표시
|
||||
showContextMenu(event, node) {
|
||||
// TODO: 우클릭 컨텍스트 메뉴 구현
|
||||
console.log('컨텍스트 메뉴:', node.title);
|
||||
},
|
||||
|
||||
// 노드 메뉴 표시
|
||||
showNodeMenu(event, node) {
|
||||
// TODO: 노드 옵션 메뉴 구현
|
||||
console.log('노드 메뉴:', node.title);
|
||||
},
|
||||
|
||||
// 정사 경로 토글
|
||||
async toggleCanonical(node) {
|
||||
try {
|
||||
const newCanonicalState = !node.is_canonical;
|
||||
console.log(`🌟 정사 경로 토글: ${node.title} (${newCanonicalState ? '설정' : '해제'})`);
|
||||
|
||||
const updatedData = {
|
||||
is_canonical: newCanonicalState
|
||||
};
|
||||
|
||||
const updatedNode = await window.api.updateMemoNode(node.id, updatedData);
|
||||
|
||||
// 로컬 상태 업데이트 (Alpine.js 반응성 보장)
|
||||
const nodeIndex = this.treeNodes.findIndex(n => n.id === node.id);
|
||||
if (nodeIndex !== -1) {
|
||||
// 배열 전체를 새로 생성하여 Alpine.js 반응성 트리거
|
||||
this.treeNodes = this.treeNodes.map(n =>
|
||||
n.id === node.id ? { ...n, ...updatedNode } : n
|
||||
);
|
||||
}
|
||||
|
||||
// 선택된 노드도 업데이트
|
||||
if (this.selectedNode && this.selectedNode.id === node.id) {
|
||||
this.selectedNode = { ...this.selectedNode, ...updatedNode };
|
||||
}
|
||||
|
||||
// 강제 리렌더링을 위한 더미 업데이트
|
||||
this.treeNodes = [...this.treeNodes];
|
||||
|
||||
// 트리 다시 그리기 (연결선 업데이트)
|
||||
this.$nextTick(() => {
|
||||
this.calculateNodePositions();
|
||||
});
|
||||
|
||||
console.log(`✅ 정사 경로 ${newCanonicalState ? '설정' : '해제'} 완료`);
|
||||
console.log('📊 업데이트된 노드:', updatedNode);
|
||||
console.log('🔍 is_canonical 값:', updatedNode.is_canonical);
|
||||
console.log('🔍 canonical_order 값:', updatedNode.canonical_order);
|
||||
console.log('🔄 현재 treeNodes 개수:', this.treeNodes.length);
|
||||
|
||||
// 업데이트된 노드 찾기
|
||||
const updatedNodeInArray = this.treeNodes.find(n => n.id === node.id);
|
||||
console.log('🔍 배열 내 업데이트된 노드:', updatedNodeInArray?.is_canonical);
|
||||
} catch (error) {
|
||||
console.error('❌ 정사 경로 토글 실패:', error);
|
||||
alert('정사 경로 설정 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
// 노드 드래그 시작
|
||||
startDragNode(event, node) {
|
||||
// 현재는 드래그 기능 비활성화 (패닝과 충돌 방지)
|
||||
event.stopPropagation();
|
||||
console.log('드래그 시작:', node.title);
|
||||
// TODO: 노드 드래그 앤 드롭 구현
|
||||
},
|
||||
|
||||
// 노드 인라인 편집
|
||||
editNodeInline(node) {
|
||||
// 더블클릭 시 노드 선택하고 에디터로 포커스
|
||||
this.selectNode(node);
|
||||
|
||||
// 에디터가 있다면 포커스
|
||||
this.$nextTick(() => {
|
||||
const editorContainer = document.getElementById('editor-container');
|
||||
if (editorContainer && this.monacoEditor) {
|
||||
this.monacoEditor.focus();
|
||||
}
|
||||
});
|
||||
|
||||
console.log('인라인 편집:', node.title);
|
||||
},
|
||||
|
||||
// 노드 위치 계산 및 반환
|
||||
getNodePosition(node) {
|
||||
if (!this.nodePositions.has(node.id)) {
|
||||
this.calculateNodePositions();
|
||||
}
|
||||
|
||||
const pos = this.nodePositions.get(node.id) || { x: 0, y: 0 };
|
||||
return `left: ${pos.x}px; top: ${pos.y}px;`;
|
||||
},
|
||||
|
||||
// 트리 노드 위치 자동 계산
|
||||
calculateNodePositions() {
|
||||
const canvas = document.getElementById('tree-canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
const canvasWidth = canvas.clientWidth;
|
||||
const canvasHeight = canvas.clientHeight;
|
||||
|
||||
// 노드 크기 설정
|
||||
const nodeWidth = 200;
|
||||
const nodeHeight = 80;
|
||||
const levelHeight = 150; // 레벨 간 간격
|
||||
const nodeSpacing = 50; // 노드 간 간격
|
||||
|
||||
// 레벨별 노드 그룹화
|
||||
const levels = new Map();
|
||||
|
||||
// 루트 노드들 찾기
|
||||
const rootNodes = this.treeNodes.filter(node => !node.parent_id);
|
||||
|
||||
// BFS로 레벨별 노드 배치
|
||||
const queue = [];
|
||||
rootNodes.forEach(node => {
|
||||
queue.push({ node, level: 0 });
|
||||
});
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { node, level } = queue.shift();
|
||||
|
||||
if (!levels.has(level)) {
|
||||
levels.set(level, []);
|
||||
}
|
||||
levels.get(level).push(node);
|
||||
|
||||
// 자식 노드들을 다음 레벨에 추가
|
||||
const children = this.getChildNodes(node.id);
|
||||
children.forEach(child => {
|
||||
queue.push({ node: child, level: level + 1 });
|
||||
});
|
||||
}
|
||||
|
||||
// 각 레벨의 노드들 위치 계산
|
||||
levels.forEach((nodes, level) => {
|
||||
const y = 100 + level * levelHeight; // 상단 여백 + 레벨 * 높이
|
||||
const totalWidth = nodes.length * nodeWidth + (nodes.length - 1) * nodeSpacing;
|
||||
const startX = (canvasWidth - totalWidth) / 2;
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
const x = startX + index * (nodeWidth + nodeSpacing);
|
||||
this.nodePositions.set(node.id, { x, y });
|
||||
});
|
||||
});
|
||||
|
||||
// 연결선 다시 그리기
|
||||
this.drawConnections();
|
||||
},
|
||||
|
||||
// SVG 연결선 그리기
|
||||
drawConnections() {
|
||||
const svg = document.getElementById('tree-connections');
|
||||
if (!svg) return;
|
||||
|
||||
// 기존 연결선 제거
|
||||
svg.innerHTML = '';
|
||||
|
||||
// 각 노드의 자식들과 연결선 그리기
|
||||
this.treeNodes.forEach(node => {
|
||||
const children = this.getChildNodes(node.id);
|
||||
if (children.length === 0) return;
|
||||
|
||||
const parentPos = this.nodePositions.get(node.id);
|
||||
if (!parentPos) return;
|
||||
|
||||
children.forEach(child => {
|
||||
const childPos = this.nodePositions.get(child.id);
|
||||
if (!childPos) return;
|
||||
|
||||
// 연결선 생성
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
|
||||
// 부모 노드 하단 중앙에서 시작
|
||||
const startX = parentPos.x + 100; // 노드 중앙
|
||||
const startY = parentPos.y + 80; // 노드 하단
|
||||
|
||||
// 자식 노드 상단 중앙으로 연결
|
||||
const endX = childPos.x + 100; // 노드 중앙
|
||||
const endY = childPos.y; // 노드 상단
|
||||
|
||||
// 곡선 경로 생성 (베지어 곡선)
|
||||
const midY = startY + (endY - startY) / 2;
|
||||
const path = `M ${startX} ${startY} C ${startX} ${midY} ${endX} ${midY} ${endX} ${endY}`;
|
||||
|
||||
line.setAttribute('d', path);
|
||||
line.setAttribute('stroke', '#9CA3AF');
|
||||
line.setAttribute('stroke-width', '2');
|
||||
line.setAttribute('fill', 'none');
|
||||
line.setAttribute('marker-end', 'url(#arrowhead)');
|
||||
|
||||
svg.appendChild(line);
|
||||
});
|
||||
});
|
||||
|
||||
// 화살표 마커 추가 (한 번만)
|
||||
if (!svg.querySelector('#arrowhead')) {
|
||||
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
||||
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
|
||||
marker.setAttribute('id', 'arrowhead');
|
||||
marker.setAttribute('markerWidth', '10');
|
||||
marker.setAttribute('markerHeight', '7');
|
||||
marker.setAttribute('refX', '9');
|
||||
marker.setAttribute('refY', '3.5');
|
||||
marker.setAttribute('orient', 'auto');
|
||||
|
||||
const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
||||
polygon.setAttribute('points', '0 0, 10 3.5, 0 7');
|
||||
polygon.setAttribute('fill', '#9CA3AF');
|
||||
|
||||
marker.appendChild(polygon);
|
||||
defs.appendChild(marker);
|
||||
svg.appendChild(defs);
|
||||
}
|
||||
},
|
||||
|
||||
// 트리 중앙 정렬
|
||||
centerTree() {
|
||||
this.treePanX = 0;
|
||||
this.treePanY = 0;
|
||||
this.treeZoom = 1;
|
||||
},
|
||||
|
||||
// 확대
|
||||
zoomIn() {
|
||||
this.treeZoom = Math.min(this.treeZoom * 1.2, 3);
|
||||
},
|
||||
|
||||
// 축소
|
||||
zoomOut() {
|
||||
this.treeZoom = Math.max(this.treeZoom / 1.2, 0.3);
|
||||
},
|
||||
|
||||
// 휠 이벤트 처리 (확대/축소)
|
||||
handleWheel(event) {
|
||||
event.preventDefault();
|
||||
if (event.deltaY < 0) {
|
||||
this.zoomIn();
|
||||
} else {
|
||||
this.zoomOut();
|
||||
}
|
||||
},
|
||||
|
||||
// 패닝 시작
|
||||
startPan(event) {
|
||||
if (event.target.closest('.tree-diagram-node')) return; // 노드 클릭 시 패닝 방지
|
||||
|
||||
this.isPanning = true;
|
||||
this.panStartX = event.clientX - this.treePanX;
|
||||
this.panStartY = event.clientY - this.treePanY;
|
||||
|
||||
document.addEventListener('mousemove', this.handlePan.bind(this));
|
||||
document.addEventListener('mouseup', this.stopPan.bind(this));
|
||||
},
|
||||
|
||||
// 패닝 처리
|
||||
handlePan(event) {
|
||||
if (!this.isPanning) return;
|
||||
|
||||
this.treePanX = event.clientX - this.panStartX;
|
||||
this.treePanY = event.clientY - this.panStartY;
|
||||
},
|
||||
|
||||
// 패닝 종료
|
||||
stopPan() {
|
||||
this.isPanning = false;
|
||||
document.removeEventListener('mousemove', this.handlePan);
|
||||
document.removeEventListener('mouseup', this.stopPan);
|
||||
},
|
||||
|
||||
// 재귀적 트리 노드 렌더링
|
||||
renderTreeNodeRecursive(node, depth, isLast = false, parentPath = []) {
|
||||
const hasChildren = this.getChildNodes(node.id).length > 0;
|
||||
const isExpanded = this.expandedNodes.has(node.id);
|
||||
const isSelected = this.selectedNode && this.selectedNode.id === node.id;
|
||||
const isRoot = depth === 0;
|
||||
|
||||
// 상태별 색상
|
||||
let statusClass = 'text-gray-700';
|
||||
switch(node.status) {
|
||||
case 'draft': statusClass = 'text-gray-500'; break;
|
||||
case 'writing': statusClass = 'text-yellow-600'; break;
|
||||
case 'review': statusClass = 'text-blue-600'; break;
|
||||
case 'complete': statusClass = 'text-green-600'; break;
|
||||
}
|
||||
|
||||
// 트리 라인 생성
|
||||
let treeLines = '';
|
||||
for (let i = 0; i < depth; i++) {
|
||||
if (i < parentPath.length && parentPath[i]) {
|
||||
// 부모가 마지막이 아니면 세로선 표시
|
||||
treeLines += '<span class="tree-line vertical"></span>';
|
||||
} else {
|
||||
// 빈 공간
|
||||
treeLines += '<span class="tree-line empty"></span>';
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 노드의 연결선
|
||||
let nodeConnector = '';
|
||||
if (!isRoot) {
|
||||
if (isLast) {
|
||||
nodeConnector = '<span class="tree-line corner"></span>'; // └─
|
||||
} else {
|
||||
nodeConnector = '<span class="tree-line branch"></span>'; // ├─
|
||||
}
|
||||
}
|
||||
|
||||
let html = `
|
||||
<div class="tree-node-item ${isRoot ? 'root' : ''} ${isLast ? 'last-child' : ''}">
|
||||
<div
|
||||
class="tree-node py-1 cursor-pointer flex items-center hover:bg-gray-50 group ${isSelected ? 'bg-blue-50 border-r-2 border-blue-500' : ''} ${statusClass}"
|
||||
onclick="window.memoTreeInstance.selectNode(${JSON.stringify(node).replace(/"/g, '"')})"
|
||||
oncontextmenu="event.preventDefault(); window.memoTreeInstance.showContextMenu(event, ${JSON.stringify(node).replace(/"/g, '"')})"
|
||||
>
|
||||
<!-- 트리 라인들 -->
|
||||
<div class="flex items-center">
|
||||
${treeLines}
|
||||
${nodeConnector}
|
||||
|
||||
<!-- 펼치기/접기 버튼 -->
|
||||
${hasChildren ?
|
||||
`<button
|
||||
onclick="event.stopPropagation(); window.memoTreeInstance.toggleNode('${node.id}')"
|
||||
class="w-4 h-4 flex items-center justify-center text-gray-400 hover:text-gray-600 mr-1 ml-1"
|
||||
>
|
||||
<i class="fas fa-${isExpanded ? 'minus' : 'plus'}-square text-xs"></i>
|
||||
</button>` :
|
||||
'<span class="w-4 mr-1 ml-1"></span>'
|
||||
}
|
||||
|
||||
<!-- 아이콘 -->
|
||||
<span class="mr-2 text-sm">${this.getNodeIcon(node.node_type)}</span>
|
||||
|
||||
<!-- 제목 -->
|
||||
<span class="flex-1 truncate font-medium">${node.title}</span>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼들 (호버 시 표시) -->
|
||||
<div class="opacity-0 group-hover:opacity-100 flex space-x-1 ml-2">
|
||||
<button
|
||||
onclick="event.stopPropagation(); window.memoTreeInstance.addChildNode(${JSON.stringify(node).replace(/"/g, '"')})"
|
||||
class="w-6 h-6 flex items-center justify-center text-gray-400 hover:text-green-600 hover:bg-green-50 rounded"
|
||||
title="자식 노드 추가"
|
||||
>
|
||||
<i class="fas fa-plus text-xs"></i>
|
||||
</button>
|
||||
<button
|
||||
onclick="event.stopPropagation(); window.memoTreeInstance.showNodeMenu(event, ${JSON.stringify(node).replace(/"/g, '"')})"
|
||||
class="w-6 h-6 flex items-center justify-center text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded"
|
||||
title="더보기"
|
||||
>
|
||||
<i class="fas fa-ellipsis-h text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 단어 수 -->
|
||||
${node.word_count > 0 ?
|
||||
`<span class="text-xs text-gray-400 ml-2">${node.word_count}w</span>` :
|
||||
''
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 자식 노드들 재귀적으로 렌더링
|
||||
if (hasChildren && isExpanded) {
|
||||
const children = this.getChildNodes(node.id);
|
||||
const newParentPath = [...parentPath, !isLast];
|
||||
|
||||
children.forEach((child, index) => {
|
||||
const isChildLast = index === children.length - 1;
|
||||
html += this.renderTreeNodeRecursive(child, depth + 1, isChildLast, newParentPath);
|
||||
});
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
},
|
||||
|
||||
// 노드 토글
|
||||
toggleNode(nodeId) {
|
||||
if (this.expandedNodes.has(nodeId)) {
|
||||
this.expandedNodes.delete(nodeId);
|
||||
} else {
|
||||
this.expandedNodes.add(nodeId);
|
||||
}
|
||||
// 트리 다시 렌더링을 위해 상태 업데이트
|
||||
this.$nextTick(() => {
|
||||
this.rerenderTree();
|
||||
});
|
||||
},
|
||||
|
||||
// 트리 다시 렌더링
|
||||
rerenderTree() {
|
||||
// Alpine.js의 반응성을 트리거하기 위해 배열을 새로 할당
|
||||
this.treeNodes = [...this.treeNodes];
|
||||
// 강제로 DOM 업데이트
|
||||
this.$nextTick(() => {
|
||||
// 트리 컨테이너 찾아서 다시 렌더링
|
||||
const treeContainer = document.querySelector('.tree-container');
|
||||
if (treeContainer) {
|
||||
// Alpine.js가 자동으로 다시 렌더링함
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 모두 펼치기
|
||||
expandAll() {
|
||||
this.treeNodes.forEach(node => {
|
||||
if (this.getChildNodes(node.id).length > 0) {
|
||||
this.expandedNodes.add(node.id);
|
||||
}
|
||||
});
|
||||
this.rerenderTree();
|
||||
},
|
||||
|
||||
// 모두 접기
|
||||
collapseAll() {
|
||||
this.expandedNodes.clear();
|
||||
this.rerenderTree();
|
||||
},
|
||||
|
||||
// Monaco Editor 초기화
|
||||
async initMonacoEditor() {
|
||||
console.log('🎨 Monaco Editor 초기화 시작...');
|
||||
|
||||
// Monaco Editor 로더가 로드될 때까지 대기
|
||||
let retries = 0;
|
||||
while (typeof require === 'undefined' && retries < 30) {
|
||||
console.log(`⏳ Monaco Editor 로더 대기 중... (${retries + 1}/30)`);
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
retries++;
|
||||
}
|
||||
|
||||
if (typeof require === 'undefined') {
|
||||
console.warn('❌ Monaco Editor 로더를 찾을 수 없습니다. 기본 textarea를 사용합니다.');
|
||||
this.setupFallbackEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
require.config({
|
||||
paths: {
|
||||
vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.44.0/min/vs'
|
||||
}
|
||||
});
|
||||
|
||||
require(['vs/editor/editor.main'], () => {
|
||||
// 에디터 컨테이너가 생성될 때까지 대기
|
||||
const waitForEditor = () => {
|
||||
const editorElement = document.getElementById('monaco-editor');
|
||||
if (!editorElement) {
|
||||
console.log('⏳ Monaco Editor 컨테이너 대기 중...');
|
||||
setTimeout(waitForEditor, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 Monaco Editor가 생성되어 있다면 제거
|
||||
if (monacoEditor) {
|
||||
monacoEditor.dispose();
|
||||
monacoEditor = null;
|
||||
}
|
||||
|
||||
monacoEditor = monaco.editor.create(editorElement, {
|
||||
value: '',
|
||||
language: 'markdown',
|
||||
theme: 'vs-light',
|
||||
automaticLayout: true,
|
||||
wordWrap: 'on',
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
folding: true,
|
||||
renderWhitespace: 'boundary'
|
||||
});
|
||||
|
||||
// 내용 변경 감지
|
||||
monacoEditor.onDidChangeModelContent(() => {
|
||||
this.isEditorDirty = true;
|
||||
});
|
||||
|
||||
console.log('✅ Monaco Editor 초기화 완료');
|
||||
};
|
||||
|
||||
// 에디터 컨테이너 대기 시작
|
||||
waitForEditor();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Monaco Editor 초기화 실패:', error);
|
||||
this.setupFallbackEditor();
|
||||
}
|
||||
},
|
||||
|
||||
// 폴백 에디터 설정 (Monaco가 실패했을 때)
|
||||
setupFallbackEditor() {
|
||||
console.log('📝 폴백 textarea 에디터 설정 중...');
|
||||
const editorElement = document.getElementById('monaco-editor');
|
||||
if (editorElement) {
|
||||
editorElement.innerHTML = `
|
||||
<textarea
|
||||
id="fallback-editor"
|
||||
class="w-full h-full p-4 border-none resize-none focus:outline-none"
|
||||
placeholder="여기에 내용을 입력하세요..."
|
||||
></textarea>
|
||||
`;
|
||||
|
||||
const textarea = document.getElementById('fallback-editor');
|
||||
if (textarea) {
|
||||
textarea.addEventListener('input', () => {
|
||||
this.isEditorDirty = true;
|
||||
});
|
||||
console.log('✅ 폴백 에디터 설정 완료');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 로그인 모달 열기
|
||||
openLoginModal() {
|
||||
this.showLoginModal = true;
|
||||
this.loginForm = { email: '', password: '' };
|
||||
this.loginError = '';
|
||||
},
|
||||
|
||||
// 로그인 처리
|
||||
async handleLogin() {
|
||||
this.loginLoading = true;
|
||||
this.loginError = '';
|
||||
|
||||
try {
|
||||
const response = await window.api.login(this.loginForm.email, this.loginForm.password);
|
||||
|
||||
if (response.success) {
|
||||
this.currentUser = response.user;
|
||||
this.showLoginModal = false;
|
||||
|
||||
// 트리 목록 다시 로드
|
||||
await this.loadUserTrees();
|
||||
} else {
|
||||
this.loginError = response.message || '로그인에 실패했습니다.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('로그인 오류:', error);
|
||||
this.loginError = '로그인 중 오류가 발생했습니다.';
|
||||
} finally {
|
||||
this.loginLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 로그아웃
|
||||
async logout() {
|
||||
try {
|
||||
await window.api.logout();
|
||||
this.currentUser = null;
|
||||
this.userTrees = [];
|
||||
this.selectedTree = null;
|
||||
this.treeNodes = [];
|
||||
this.selectedNode = null;
|
||||
console.log('✅ 로그아웃 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 로그아웃 실패:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// 전역 인스턴스 등록 (HTML에서 접근하기 위해)
|
||||
window.memoTreeInstance = null;
|
||||
|
||||
// Alpine.js 초기화 후 전역 인스턴스 설정
|
||||
document.addEventListener('alpine:init', () => {
|
||||
// 페이지 로드 후 인스턴스 등록
|
||||
setTimeout(() => {
|
||||
const appElement = document.querySelector('[x-data="memoTreeApp()"]');
|
||||
if (appElement && appElement._x_dataStack) {
|
||||
window.memoTreeInstance = appElement._x_dataStack[0];
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// 트리 노드 컴포넌트
|
||||
window.treeNodeComponent = function(node) {
|
||||
return {
|
||||
node: node,
|
||||
expanded: true,
|
||||
|
||||
get hasChildren() {
|
||||
return window.memoTreeInstance?.getChildNodes(this.node.id).length > 0;
|
||||
},
|
||||
|
||||
toggleExpanded() {
|
||||
this.expanded = !this.expanded;
|
||||
if (this.expanded) {
|
||||
window.memoTreeInstance?.expandedNodes.add(this.node.id);
|
||||
} else {
|
||||
window.memoTreeInstance?.expandedNodes.delete(this.node.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
console.log('🌳 트리 메모장 JavaScript 로드 완료');
|
||||
345
frontend/static/js/story-view.js
Normal file
345
frontend/static/js/story-view.js
Normal file
@@ -0,0 +1,345 @@
|
||||
// story-view.js - 정사 경로 스토리 뷰 컴포넌트
|
||||
|
||||
console.log('📖 스토리 뷰 JavaScript 로드 완료');
|
||||
|
||||
// Alpine.js 컴포넌트
|
||||
window.storyViewApp = function() {
|
||||
return {
|
||||
// 사용자 상태
|
||||
currentUser: null,
|
||||
|
||||
// 트리 데이터
|
||||
userTrees: [],
|
||||
selectedTreeId: '',
|
||||
selectedTree: null,
|
||||
canonicalNodes: [], // 정사 경로 노드들만
|
||||
|
||||
// UI 상태
|
||||
viewMode: 'toc', // 'toc' | 'full'
|
||||
showLoginModal: false,
|
||||
showEditModal: false,
|
||||
editingNode: null,
|
||||
|
||||
// 로그인 폼 상태
|
||||
loginForm: {
|
||||
email: '',
|
||||
password: ''
|
||||
},
|
||||
loginError: '',
|
||||
loginLoading: false,
|
||||
|
||||
// 계산된 속성들
|
||||
get totalWords() {
|
||||
return this.canonicalNodes.reduce((sum, node) => sum + (node.word_count || 0), 0);
|
||||
},
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('📖 스토리 뷰 초기화 중...');
|
||||
|
||||
// API 로드 대기
|
||||
let retryCount = 0;
|
||||
while (!window.api && retryCount < 50) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
retryCount++;
|
||||
}
|
||||
|
||||
if (!window.api) {
|
||||
console.error('❌ API가 로드되지 않았습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자 인증 상태 확인
|
||||
await this.checkAuthStatus();
|
||||
|
||||
// 인증된 경우 트리 목록 로드
|
||||
if (this.currentUser) {
|
||||
await this.loadUserTrees();
|
||||
}
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const user = await window.api.getCurrentUser();
|
||||
this.currentUser = user;
|
||||
console.log('✅ 사용자 인증됨:', user.email);
|
||||
} catch (error) {
|
||||
console.log('❌ 인증되지 않음:', error.message);
|
||||
this.currentUser = null;
|
||||
|
||||
// 만료된 토큰 정리
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
},
|
||||
|
||||
// 사용자 트리 목록 로드
|
||||
async loadUserTrees() {
|
||||
try {
|
||||
console.log('📊 사용자 트리 목록 로딩...');
|
||||
const trees = await window.api.getUserMemoTrees();
|
||||
this.userTrees = trees || [];
|
||||
console.log(`✅ ${this.userTrees.length}개 트리 로드 완료`);
|
||||
} catch (error) {
|
||||
console.error('❌ 트리 목록 로드 실패:', error);
|
||||
this.userTrees = [];
|
||||
}
|
||||
},
|
||||
|
||||
// 스토리 로드 (정사 경로만)
|
||||
async loadStory(treeId) {
|
||||
if (!treeId) {
|
||||
this.selectedTree = null;
|
||||
this.canonicalNodes = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('📖 스토리 로딩:', treeId);
|
||||
|
||||
// 트리 정보 로드
|
||||
this.selectedTree = await window.api.getMemoTree(treeId);
|
||||
|
||||
// 모든 노드 로드
|
||||
const allNodes = await window.api.getMemoTreeNodes(treeId);
|
||||
|
||||
// 정사 경로 노드들만 필터링하고 순서대로 정렬
|
||||
this.canonicalNodes = allNodes
|
||||
.filter(node => node.is_canonical)
|
||||
.sort((a, b) => (a.canonical_order || 0) - (b.canonical_order || 0));
|
||||
|
||||
console.log(`✅ 스토리 로드 완료: ${this.canonicalNodes.length}개 정사 노드`);
|
||||
} catch (error) {
|
||||
console.error('❌ 스토리 로드 실패:', error);
|
||||
alert('스토리를 불러오는 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
// 뷰 모드 토글
|
||||
toggleView() {
|
||||
this.viewMode = this.viewMode === 'toc' ? 'full' : 'toc';
|
||||
},
|
||||
|
||||
// 챕터로 스크롤
|
||||
scrollToChapter(nodeId) {
|
||||
if (this.viewMode === 'toc') {
|
||||
this.viewMode = 'full';
|
||||
// DOM 업데이트 대기 후 스크롤
|
||||
this.$nextTick(() => {
|
||||
const element = document.getElementById(`chapter-${nodeId}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const element = document.getElementById(`chapter-${nodeId}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 챕터 편집 (인라인 모달)
|
||||
editChapter(node) {
|
||||
this.editingNode = { ...node }; // 복사본 생성
|
||||
this.showEditModal = true;
|
||||
},
|
||||
|
||||
// 편집 취소
|
||||
cancelEdit() {
|
||||
this.showEditModal = false;
|
||||
this.editingNode = null;
|
||||
},
|
||||
|
||||
// 편집 저장
|
||||
async saveEdit() {
|
||||
if (!this.editingNode) return;
|
||||
|
||||
try {
|
||||
console.log('💾 챕터 저장 중:', this.editingNode.title);
|
||||
|
||||
const updatedNode = await window.api.updateMemoNode(this.editingNode.id, {
|
||||
title: this.editingNode.title,
|
||||
content: this.editingNode.content
|
||||
});
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
const nodeIndex = this.canonicalNodes.findIndex(n => n.id === this.editingNode.id);
|
||||
if (nodeIndex !== -1) {
|
||||
this.canonicalNodes = this.canonicalNodes.map(n =>
|
||||
n.id === this.editingNode.id ? { ...n, ...updatedNode } : n
|
||||
);
|
||||
}
|
||||
|
||||
this.showEditModal = false;
|
||||
this.editingNode = null;
|
||||
|
||||
console.log('✅ 챕터 저장 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 챕터 저장 실패:', error);
|
||||
alert('챕터 저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
// 스토리 내보내기
|
||||
async exportStory() {
|
||||
if (!this.selectedTree || this.canonicalNodes.length === 0) {
|
||||
alert('내보낼 스토리가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 텍스트 형태로 스토리 생성
|
||||
let storyText = `${this.selectedTree.title}\n`;
|
||||
storyText += `${'='.repeat(this.selectedTree.title.length)}\n\n`;
|
||||
|
||||
if (this.selectedTree.description) {
|
||||
storyText += `${this.selectedTree.description}\n\n`;
|
||||
}
|
||||
|
||||
storyText += `작성일: ${this.formatDate(this.selectedTree.created_at)}\n`;
|
||||
storyText += `수정일: ${this.formatDate(this.selectedTree.updated_at)}\n`;
|
||||
storyText += `총 ${this.canonicalNodes.length}개 챕터, ${this.totalWords}단어\n\n`;
|
||||
storyText += `${'='.repeat(50)}\n\n`;
|
||||
|
||||
this.canonicalNodes.forEach((node, index) => {
|
||||
storyText += `${index + 1}. ${node.title}\n`;
|
||||
storyText += `${'-'.repeat(node.title.length + 3)}\n\n`;
|
||||
|
||||
if (node.content) {
|
||||
// HTML 태그 제거
|
||||
const plainText = node.content.replace(/<[^>]*>/g, '');
|
||||
storyText += `${plainText}\n\n`;
|
||||
} else {
|
||||
storyText += `[이 챕터는 아직 내용이 없습니다]\n\n`;
|
||||
}
|
||||
|
||||
storyText += `${'─'.repeat(30)}\n\n`;
|
||||
});
|
||||
|
||||
// 파일 다운로드
|
||||
const blob = new Blob([storyText], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${this.selectedTree.title}_정사스토리.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
console.log('✅ 스토리 내보내기 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 스토리 내보내기 실패:', error);
|
||||
alert('스토리 내보내기 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
// 스토리 인쇄
|
||||
printStory() {
|
||||
if (!this.selectedTree || this.canonicalNodes.length === 0) {
|
||||
alert('인쇄할 스토리가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 전체 뷰로 변경 후 인쇄
|
||||
if (this.viewMode === 'toc') {
|
||||
this.viewMode = 'full';
|
||||
this.$nextTick(() => {
|
||||
window.print();
|
||||
});
|
||||
} else {
|
||||
window.print();
|
||||
}
|
||||
},
|
||||
|
||||
// 유틸리티 함수들
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
},
|
||||
|
||||
formatContent(content) {
|
||||
if (!content) return '';
|
||||
|
||||
// 간단한 마크다운 스타일 변환
|
||||
return content
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/^/, '<p>')
|
||||
.replace(/$/, '</p>');
|
||||
},
|
||||
|
||||
getNodeTypeLabel(nodeType) {
|
||||
const labels = {
|
||||
'folder': '📁 폴더',
|
||||
'memo': '📝 메모',
|
||||
'chapter': '📖 챕터',
|
||||
'character': '👤 인물',
|
||||
'plot': '📋 플롯'
|
||||
};
|
||||
return labels[nodeType] || '📝 메모';
|
||||
},
|
||||
|
||||
getStatusLabel(status) {
|
||||
const labels = {
|
||||
'draft': '📝 초안',
|
||||
'writing': '✍️ 작성중',
|
||||
'review': '👀 검토중',
|
||||
'complete': '✅ 완료'
|
||||
};
|
||||
return labels[status] || '📝 초안';
|
||||
},
|
||||
|
||||
// 로그인 관련 함수들
|
||||
openLoginModal() {
|
||||
this.showLoginModal = true;
|
||||
this.loginForm = { email: '', password: '' };
|
||||
this.loginError = '';
|
||||
},
|
||||
|
||||
async handleLogin() {
|
||||
this.loginLoading = true;
|
||||
this.loginError = '';
|
||||
|
||||
try {
|
||||
const response = await window.api.login(this.loginForm.email, this.loginForm.password);
|
||||
|
||||
if (response.success) {
|
||||
this.currentUser = response.user;
|
||||
this.showLoginModal = false;
|
||||
|
||||
// 트리 목록 다시 로드
|
||||
await this.loadUserTrees();
|
||||
} else {
|
||||
this.loginError = response.message || '로그인에 실패했습니다.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('로그인 오류:', error);
|
||||
this.loginError = '로그인 중 오류가 발생했습니다.';
|
||||
} finally {
|
||||
this.loginLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await window.api.logout();
|
||||
this.currentUser = null;
|
||||
this.userTrees = [];
|
||||
this.selectedTree = null;
|
||||
this.canonicalNodes = [];
|
||||
console.log('✅ 로그아웃 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 로그아웃 실패:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
console.log('📖 스토리 뷰 컴포넌트 등록 완료');
|
||||
389
frontend/story-view.html
Normal file
389
frontend/story-view.html
Normal file
@@ -0,0 +1,389 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>스토리 뷰 - 정사 경로</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
/* 목차 스타일 */
|
||||
.toc-item {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.toc-item:hover {
|
||||
background-color: #f8fafc;
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding-left: 16px;
|
||||
}
|
||||
.toc-number {
|
||||
min-width: 2rem;
|
||||
}
|
||||
.story-content {
|
||||
line-height: 1.8;
|
||||
}
|
||||
.chapter-divider {
|
||||
background: linear-gradient(90deg, #e5e7eb 0%, #9ca3af 50%, #e5e7eb 100%);
|
||||
height: 1px;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
/* 프린트 스타일 */
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.story-content { font-size: 12pt; line-height: 1.6; }
|
||||
.toc-item { break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50" x-data="storyViewApp()" x-init="init()">
|
||||
<!-- 헤더 -->
|
||||
<header class="bg-white shadow-sm border-b no-print">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- 왼쪽: 로고 및 네비게이션 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="index.html" class="flex items-center space-x-2">
|
||||
<i class="fas fa-book text-blue-600 text-xl"></i>
|
||||
<span class="text-xl font-bold text-gray-900">Document Server</span>
|
||||
</a>
|
||||
<span class="text-gray-400">|</span>
|
||||
<nav class="flex space-x-4">
|
||||
<a href="memo-tree.html" class="text-gray-600 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium">
|
||||
<i class="fas fa-sitemap mr-1"></i> 트리 에디터
|
||||
</a>
|
||||
<a href="story-view.html" class="bg-blue-100 text-blue-700 px-3 py-2 rounded-md text-sm font-medium">
|
||||
<i class="fas fa-book-open mr-1"></i> 스토리 뷰
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 사용자 정보 및 액션 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<div x-show="currentUser" class="flex items-center space-x-3">
|
||||
<span class="text-sm text-gray-600" x-text="currentUser?.full_name || currentUser?.email"></span>
|
||||
<button @click="logout()" class="text-sm text-gray-500 hover:text-gray-700">로그아웃</button>
|
||||
</div>
|
||||
<button x-show="!currentUser" @click="openLoginModal()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
로그인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="min-h-screen pt-4" x-show="currentUser">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
|
||||
<!-- 상단 툴바 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6 no-print">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- 트리 선택 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<select
|
||||
x-model="selectedTreeId"
|
||||
@change="loadStory(selectedTreeId)"
|
||||
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">📚 스토리 선택</option>
|
||||
<template x-for="tree in userTrees" :key="tree.id">
|
||||
<option :value="tree.id" x-text="`📖 ${tree.title}`"></option>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<div x-show="selectedTree" class="text-sm text-gray-600">
|
||||
<span x-text="`총 ${canonicalNodes.length}개 챕터`"></span>
|
||||
<span class="mx-2">•</span>
|
||||
<span x-text="`${totalWords}단어`"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼들 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="toggleView()"
|
||||
class="px-3 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 text-sm"
|
||||
>
|
||||
<i class="fas fa-eye mr-1"></i>
|
||||
<span x-text="viewMode === 'toc' ? '전체보기' : '목차보기'"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="exportStory()"
|
||||
class="px-3 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
|
||||
>
|
||||
<i class="fas fa-download mr-1"></i> 내보내기
|
||||
</button>
|
||||
<button
|
||||
@click="printStory()"
|
||||
class="px-3 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 text-sm"
|
||||
>
|
||||
<i class="fas fa-print mr-1"></i> 인쇄
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스토리 없음 상태 -->
|
||||
<div x-show="!selectedTree" class="bg-white rounded-lg shadow-sm p-12 text-center">
|
||||
<i class="fas fa-book-open text-6xl text-gray-300 mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">스토리를 선택하세요</h3>
|
||||
<p class="text-gray-500">위에서 트리를 선택하면 정사 경로가 목차 형태로 표시됩니다</p>
|
||||
</div>
|
||||
|
||||
<!-- 정사 노드 없음 상태 -->
|
||||
<div x-show="selectedTree && canonicalNodes.length === 0" class="bg-white rounded-lg shadow-sm p-12 text-center">
|
||||
<i class="fas fa-route text-6xl text-gray-300 mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">정사 경로가 없습니다</h3>
|
||||
<p class="text-gray-500 mb-4">트리 에디터에서 노드들을 정사로 설정해주세요</p>
|
||||
<a href="memo-tree.html" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
<i class="fas fa-sitemap mr-2"></i> 트리 에디터로 가기
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 스토리 컨텐츠 -->
|
||||
<div x-show="selectedTree && canonicalNodes.length > 0" class="space-y-6">
|
||||
|
||||
<!-- 스토리 헤더 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2" x-text="selectedTree?.title"></h1>
|
||||
<p class="text-gray-600 mb-4" x-text="selectedTree?.description || '소설 설명이 없습니다'"></p>
|
||||
<div class="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span><i class="fas fa-calendar mr-1"></i> <span x-text="formatDate(selectedTree?.created_at)"></span></span>
|
||||
<span><i class="fas fa-edit mr-1"></i> <span x-text="formatDate(selectedTree?.updated_at)"></span></span>
|
||||
<span><i class="fas fa-list-ol mr-1"></i> <span x-text="`${canonicalNodes.length}개 챕터`"></span></span>
|
||||
<span><i class="fas fa-font mr-1"></i> <span x-text="`${totalWords}단어`"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 목차 뷰 -->
|
||||
<div x-show="viewMode === 'toc'" class="bg-white rounded-lg shadow-sm">
|
||||
<div class="p-6 border-b">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-list mr-2"></i> 목차 (정사 경로)
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-2">
|
||||
<template x-for="(node, index) in canonicalNodes" :key="node.id">
|
||||
<div
|
||||
class="toc-item flex items-center p-3 rounded-lg cursor-pointer"
|
||||
@click="scrollToChapter(node.id)"
|
||||
>
|
||||
<span class="toc-number text-sm font-medium text-gray-500 mr-3" x-text="`${index + 1}.`"></span>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-medium text-gray-900" x-text="node.title"></h3>
|
||||
<div class="flex items-center space-x-3 text-xs text-gray-500 mt-1">
|
||||
<span x-text="getNodeTypeLabel(node.node_type)"></span>
|
||||
<span x-show="node.word_count > 0" x-text="`${node.word_count}단어`"></span>
|
||||
<span x-text="getStatusLabel(node.status)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 전체 스토리 뷰 -->
|
||||
<div x-show="viewMode === 'full'" class="bg-white rounded-lg shadow-sm">
|
||||
<div class="p-6 border-b">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-book-open mr-2"></i> 전체 스토리
|
||||
</h2>
|
||||
</div>
|
||||
<div class="story-content">
|
||||
<template x-for="(node, index) in canonicalNodes" :key="node.id">
|
||||
<div :id="`chapter-${node.id}`" class="chapter-section">
|
||||
<!-- 챕터 헤더 -->
|
||||
<div class="p-6 bg-gray-50 border-b">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<span class="text-blue-600 mr-2" x-text="`${index + 1}.`"></span>
|
||||
<span x-text="node.title"></span>
|
||||
</h3>
|
||||
<div class="flex items-center space-x-3 text-sm text-gray-500 mt-1">
|
||||
<span x-text="getNodeTypeLabel(node.node_type)"></span>
|
||||
<span x-show="node.word_count > 0" x-text="`${node.word_count}단어`"></span>
|
||||
<span x-text="getStatusLabel(node.status)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="editChapter(node)"
|
||||
class="no-print px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded"
|
||||
>
|
||||
<i class="fas fa-edit mr-1"></i> 편집
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 챕터 내용 -->
|
||||
<div class="p-6">
|
||||
<div x-show="node.content" class="prose max-w-none" x-html="formatContent(node.content)"></div>
|
||||
<div x-show="!node.content" class="text-gray-400 italic text-center py-8">
|
||||
이 챕터는 아직 내용이 없습니다
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 챕터 구분선 -->
|
||||
<div x-show="index < canonicalNodes.length - 1" class="chapter-divider"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그인이 필요한 경우 -->
|
||||
<div x-show="!currentUser" class="flex items-center justify-center min-h-screen">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-lock text-6xl text-gray-300 mb-4"></i>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">로그인이 필요합니다</h2>
|
||||
<p class="text-gray-600 mb-6">스토리를 보려면 먼저 로그인해주세요</p>
|
||||
<button @click="openLoginModal()" class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
로그인하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 편집 모달 -->
|
||||
<div x-show="showEditModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg w-full max-w-4xl mx-4 h-5/6 flex flex-col">
|
||||
<div class="p-6 border-b">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold">챕터 편집</h2>
|
||||
<button @click="cancelEdit()" class="text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-6 overflow-hidden">
|
||||
<div class="h-full flex flex-col space-y-4">
|
||||
<!-- 제목 편집 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">제목</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="editingNode.title"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="챕터 제목을 입력하세요"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 내용 편집 -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">내용</label>
|
||||
<textarea
|
||||
x-model="editingNode.content"
|
||||
class="flex-1 w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
placeholder="챕터 내용을 입력하세요..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t bg-gray-50 flex justify-end space-x-3">
|
||||
<button
|
||||
@click="cancelEdit()"
|
||||
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
@click="saveEdit()"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
<i class="fas fa-save mr-2"></i> 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 모달 -->
|
||||
<div x-show="showLoginModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
|
||||
<h2 class="text-xl font-bold mb-4">로그인</h2>
|
||||
<form @submit.prevent="handleLogin()">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">이메일</label>
|
||||
<input
|
||||
type="email"
|
||||
x-model="loginForm.email"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
x-model="loginForm.password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
</div>
|
||||
<div x-show="loginError" class="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||
<span x-text="loginError"></span>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="showLoginModal = false"
|
||||
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loginLoading"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!loginLoading">로그인</span>
|
||||
<span x-show="loginLoading">로그인 중...</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 로딩 -->
|
||||
<script>
|
||||
// 순차적 스크립트 로딩
|
||||
async function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// Alpine.js 초기화 이벤트 리스너
|
||||
document.addEventListener('alpine:init', () => {
|
||||
console.log('Alpine.js 초기화됨');
|
||||
});
|
||||
|
||||
// 스크립트 순차 로딩
|
||||
(async () => {
|
||||
try {
|
||||
await loadScript('static/js/api.js?v=2025012290');
|
||||
console.log('✅ API 스크립트 로드 완료');
|
||||
await loadScript('static/js/story-view.js?v=2025012295');
|
||||
console.log('✅ Story View 스크립트 로드 완료');
|
||||
|
||||
// 모든 스크립트 로드 완료 후 Alpine.js 로드
|
||||
console.log('🚀 Alpine.js 로딩...');
|
||||
await loadScript('https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js');
|
||||
console.log('✅ Alpine.js 로드 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 스크립트 로드 실패:', error);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user