feat: 소설 분기 시스템 및 트리 메모장 구현

🌟 주요 기능:
- 트리 구조 메모장 시스템
- 소설 분기 관리 (정사 경로 설정)
- 중앙 배치 트리 다이어그램
- 정사 경로 목차 뷰
- 인라인 편집 기능

📚 백엔드:
- MemoTree, MemoNode 모델 추가
- 정사 경로 자동 순서 관리
- 분기점에서 하나만 선택 가능한 로직
- RESTful API 엔드포인트

🎨 프론트엔드:
- memo-tree.html: 트리 다이어그램 에디터
- story-view.html: 정사 경로 목차 뷰
- SVG 연결선으로 시각적 트리 표현
- Alpine.js 기반 반응형 UI
- Monaco Editor 통합

 특별 기능:
- 정사 경로 황금색 배지 표시
- 확대/축소 및 패닝 지원
- 드래그 앤 드롭 준비
- 내보내기 및 인쇄 기능
- 인라인 편집 모달
This commit is contained in:
Hyungi Ahn
2025-08-25 10:25:10 +09:00
parent 5bfa3822ca
commit f95f67364a
16 changed files with 3992 additions and 6 deletions

View 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;

View 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 '정사 경로 문자열 표현';

View 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 $$;

View 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)}"
)

View File

@@ -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("/")

View 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])

View File

@@ -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}')>"

View 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

View 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;

View File

@@ -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>

View File

@@ -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
View 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>

View File

@@ -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 인스턴스

View 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, '&quot;')})"
oncontextmenu="event.preventDefault(); window.memoTreeInstance.showContextMenu(event, ${JSON.stringify(node).replace(/"/g, '&quot;')})"
>
<!-- 트리 라인들 -->
<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, '&quot;')})"
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, '&quot;')})"
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 로드 완료');

View 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
View 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>