Files
document-server/frontend/static/js/story-view.js
Hyungi Ahn f95f67364a feat: 소설 분기 시스템 및 트리 메모장 구현
🌟 주요 기능:
- 트리 구조 메모장 시스템
- 소설 분기 관리 (정사 경로 설정)
- 중앙 배치 트리 다이어그램
- 정사 경로 목차 뷰
- 인라인 편집 기능

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

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

 특별 기능:
- 정사 경로 황금색 배지 표시
- 확대/축소 및 패닝 지원
- 드래그 앤 드롭 준비
- 내보내기 및 인쇄 기능
- 인라인 편집 모달
2025-08-25 10:25:10 +09:00

346 lines
12 KiB
JavaScript

// 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('📖 스토리 뷰 컴포넌트 등록 완료');