Files
document-server/frontend/memo-tree.html
Hyungi Ahn f221a5611c 전체 페이지 헤더 여백 통일 및 z-index 충돌 해결
주요 변경사항:
- story-reader.html: pt-4 → pt-20
- pdf-manager.html: py-8 → pt-20 pb-8
- upload.html: py-8 → pt-20 pb-8
- index.html: py-8 → pt-20 pb-8
- search.html: py-8 → pt-20 pb-8
- memo-tree.html: pt-16 → pt-20
- notebooks.html: py-8 → pt-20 pb-8
- notes.html: py-8 → pt-20 pb-8

헤더(h-16, z-50)와의 충돌을 방지하기 위해 모든 페이지의 상단 여백을 pt-20(80px)으로 통일
2025-09-04 10:25:57 +09:00

1282 lines
57 KiB
HTML

<!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>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<!-- 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>
<!-- 헤더 로더 -->
<script src="static/js/header-loader.js"></script>
<style>
[x-cloak] { display: none !important; }
/* 모던 UI 스타일 */
:root {
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--secondary-gradient: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--success-gradient: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
--warning-gradient: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
--glass-bg: rgba(255, 255, 255, 0.1);
--glass-border: rgba(255, 255, 255, 0.2);
--shadow-soft: 0 8px 32px rgba(31, 38, 135, 0.37);
--shadow-hover: 0 15px 35px rgba(31, 38, 135, 0.5);
}
/* 기존 깔끔한 디자인 유지 */
body {
background-color: #f8fafc;
margin: 0;
padding: 0;
}
/* 개선된 커스텀 드롭다운 스타일 */
.custom-dropdown {
position: relative;
display: inline-block;
min-width: 120px;
width: 100%;
}
.dropdown-button {
width: 100%;
padding: 10px 14px;
background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%);
border: 1.5px solid #e2e8f0;
border-radius: 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
position: relative;
overflow: hidden;
min-height: 38px;
}
.dropdown-button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(59, 130, 246, 0.08), transparent);
transition: left 0.4s ease;
}
.dropdown-button:hover::before {
left: 100%;
}
.dropdown-button:hover {
border-color: #3b82f6;
box-shadow: 0 3px 12px rgba(59, 130, 246, 0.12);
transform: translateY(-0.5px);
}
.dropdown-button.active {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.08), 0 3px 12px rgba(59, 130, 246, 0.15);
background: linear-gradient(145deg, #f0f7ff 0%, #e6f3ff 100%);
}
/* 작은 드롭다운 버튼 스타일 */
.dropdown-button.text-sm {
padding: 8px 12px;
font-size: 13px;
min-height: 34px;
border-radius: 8px;
}
.dropdown-content {
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
background: white;
border: 2px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15), 0 8px 16px rgba(0, 0, 0, 0.1);
z-index: 1000;
max-height: 320px;
overflow-y: auto;
backdrop-filter: blur(10px);
}
.dropdown-item {
padding: 14px 18px;
cursor: pointer;
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border-bottom: 1px solid #f1f5f9;
position: relative;
overflow: hidden;
}
.dropdown-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 0;
height: 100%;
background: linear-gradient(90deg, rgba(59, 130, 246, 0.1), rgba(59, 130, 246, 0.05));
transition: width 0.3s ease;
}
.dropdown-item:hover::before {
width: 100%;
}
.dropdown-item:last-child {
border-bottom: none;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
}
.dropdown-item:first-child {
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
.dropdown-item:hover {
background-color: #f8fafc;
transform: translateX(4px);
padding-left: 22px;
}
.dropdown-item.selected {
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
color: #1d4ed8;
border-left: 4px solid #3b82f6;
font-weight: 600;
}
.dropdown-item-icon {
margin-right: 12px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: transform 0.2s ease;
}
.dropdown-item:hover .dropdown-item-icon {
transform: scale(1.1);
}
/* 개선된 버튼 스타일 */
.btn-improved {
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-primary-improved {
background-color: #3b82f6;
color: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.btn-primary-improved:hover {
background-color: #2563eb;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
.btn-success-improved {
background-color: #10b981;
color: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.btn-success-improved:hover {
background-color: #059669;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
.btn-secondary-improved {
background-color: #6b7280;
color: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.btn-secondary-improved:hover {
background-color: #4b5563;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
/* 글래스모피즘 효과 강화 */
.glass {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37);
}
.glass-hover:hover {
background: rgba(255, 255, 255, 0.35);
box-shadow: 0 15px 35px rgba(31, 38, 135, 0.5);
transform: translateY(-2px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 헤더 글래스 효과 */
.header-glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(25px);
-webkit-backdrop-filter: blur(25px);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
/* 모던 버튼 스타일 */
.btn-modern {
background: var(--primary-gradient);
border: none;
border-radius: 12px;
padding: 12px 24px;
color: white;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.btn-modern:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.6);
}
.btn-success {
background: var(--success-gradient);
box-shadow: 0 4px 15px rgba(79, 172, 254, 0.4);
}
.btn-success:hover {
box-shadow: 0 8px 25px rgba(79, 172, 254, 0.6);
}
.btn-warning {
background: var(--warning-gradient);
box-shadow: 0 4px 15px rgba(67, 233, 123, 0.4);
}
.btn-warning:hover {
box-shadow: 0 8px 25px rgba(67, 233, 123, 0.6);
}
/* 모던 카드 스타일 */
.card-modern {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.card-modern:hover {
transform: translateY(-5px);
box-shadow: 0 20px 40px rgba(31, 38, 135, 0.5);
}
/* 트리 노드 모던 스타일 */
.tree-node-modern {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.4);
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
position: relative;
}
.tree-node-modern::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--primary-gradient);
opacity: 0;
transition: opacity 0.3s ease;
}
.tree-node-modern:hover::before {
opacity: 1;
}
.tree-node-modern:hover {
transform: translateY(-3px) scale(1.02);
box-shadow: 0 15px 40px rgba(31, 38, 135, 0.4);
background: rgba(255, 255, 255, 0.95);
}
/* 정사 노드 특별 스타일 */
.tree-node-canonical {
background: linear-gradient(135deg, rgba(255, 215, 0, 0.2), rgba(255, 193, 7, 0.1));
border: 2px solid rgba(255, 193, 7, 0.5);
box-shadow: 0 8px 32px rgba(255, 193, 7, 0.3);
}
.tree-node-canonical::before {
background: linear-gradient(90deg, #ffd700, #ffb347);
opacity: 1;
}
/* 정사 배지 모던 스타일 */
.canonical-badge {
background: linear-gradient(135deg, #ffd700, #ffb347);
color: #8b4513;
font-weight: 700;
font-size: 11px;
padding: 4px 8px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.4);
animation: pulse-gold 2s infinite;
}
@keyframes pulse-gold {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
/* 헤더 모던 스타일 */
.header-modern {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
/* 입력 필드 모던 스타일 */
.input-modern {
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
padding: 12px 16px;
font-size: 14px;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.input-modern:focus {
outline: none;
border-color: rgba(102, 126, 234, 0.5);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
background: rgba(255, 255, 255, 0.95);
}
/* 애니메이션 */
.fade-in {
animation: fadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.slide-up {
animation: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slideUp {
from { transform: translateY(30px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* 스크롤바 스타일링 */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
/* 기존 트리 스타일 유지 (호환성) */
.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; }
/* 드래그 앤 드롭 스타일 */
.drop-target-highlight {
background: rgba(59, 130, 246, 0.1) !important;
border: 2px dashed #3b82f6 !important;
transform: scale(1.05) !important;
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.3) !important;
}
.dragging {
opacity: 0.7;
transform: scale(1.05);
z-index: 1000;
cursor: grabbing !important;
}
</style>
</head>
<body x-data="memoTreeApp()" x-init="init()" x-cloak>
<!-- 공통 헤더 컨테이너 -->
<div id="header-container"></div>
<!-- 로그인 모달 -->
<div x-data="authModal()" x-show="showLoginModal" x-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-8 max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-900">로그인</h2>
<button @click="showLoginModal = false" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<form @submit.prevent="login">
<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>
<button type="submit" :disabled="loginLoading"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50">
<span x-show="!loginLoading">로그인</span>
<span x-show="loginLoading">로그인 중...</span>
</button>
</form>
</div>
</div>
<!-- 메인 컨테이너 -->
<div class="h-screen pt-20" x-show="currentUser">
<!-- 상단 툴바 -->
<div class="bg-white border-b shadow-sm p-4">
<div class="flex items-center justify-between">
<!-- 트리 선택 -->
<div class="flex items-center space-x-3">
<div class="custom-dropdown" x-data="{ open: false }" @click.away="open = false">
<button
class="dropdown-button"
:class="{ 'active': open }"
@click="open = !open"
>
<span class="flex items-center">
<span class="dropdown-item-icon" x-text="selectedTree ? getTreeIcon(selectedTree.tree_type) : '📁'"></span>
<span x-text="selectedTree ? selectedTree.title : '트리 선택'"></span>
</span>
<i class="fas fa-chevron-down transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<div x-show="open" x-transition class="dropdown-content">
<template x-for="tree in userTrees" :key="tree.id">
<div
class="dropdown-item"
:class="{ 'selected': selectedTreeId === tree.id }"
@click="selectedTreeId = tree.id; loadTree(tree.id); open = false"
>
<span class="dropdown-item-icon" x-text="getTreeIcon(tree.tree_type)"></span>
<span x-text="tree.title"></span>
</div>
</template>
</div>
</div>
<button
@click="showNewTreeModal = true"
class="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 transition-colors duration-200 flex items-center whitespace-nowrap"
title="새 트리 생성"
>
<i class="fas fa-plus mr-2"></i>새 트리
</button>
</div>
<!-- 트리 액션 버튼들 -->
<div x-show="selectedTree" class="flex items-center space-x-2">
<button
@click="createRootNode()"
class="btn-improved btn-success-improved"
>
<i class="fas fa-plus"></i> 노드 추가
</button>
<button
@click="centerTree()"
class="btn-improved btn-secondary-improved"
title="트리 중앙 정렬"
>
<i class="fas fa-crosshairs"></i> 중앙정렬
</button>
<button
@click="zoomIn()"
class="btn-improved btn-secondary-improved"
title="확대"
>
<i class="fas fa-search-plus"></i>
</button>
<button
@click="zoomOut()"
class="btn-improved btn-secondary-improved"
title="축소"
>
<i class="fas fa-search-minus"></i>
</button>
</div>
</div>
</div>
<!-- 메인 트리 다이어그램 영역 -->
<div class="flex h-full overflow-hidden">
<!-- 트리 다이어그램 (중앙) -->
<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)"
>
<!-- 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)"
:data-node-id="node.id"
@click="selectNode(node)"
>
<div
class="tree-node-modern p-4 cursor-pointer min-w-40 max-w-56 relative"
:class="{
'tree-node-canonical': node.is_canonical,
'ring-2 ring-blue-400': selectedNode && selectedNode.id === node.id
}"
@dblclick="editNodeInline(node)"
>
<!-- 정사 경로 배지 -->
<div x-show="node.is_canonical" class="absolute -top-3 -right-3 canonical-badge">
<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 xl:w-96 bg-white border-l shadow-lg flex flex-col min-w-0">
<!-- 에디터 헤더 -->
<template x-if="selectedNode">
<div class="p-3 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="mt-3">
<!-- 타입/상태와 저장/삭제를 좌우로 배치 -->
<div class="flex items-start gap-4">
<!-- 왼쪽: 타입과 상태 -->
<div class="flex-1 space-y-4">
<!-- 타입 선택 -->
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">타입</label>
<div class="custom-dropdown" x-data="{ open: false }" @click.away="open = false">
<button
class="dropdown-button text-sm"
:class="{ 'active': open }"
@click="open = !open"
>
<span class="flex items-center">
<span class="dropdown-item-icon" x-text="getNodeIcon(selectedNode.node_type)"></span>
<span x-text="getNodeTypeLabel(selectedNode.node_type)"></span>
</span>
<i class="fas fa-chevron-down transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<div x-show="open" x-transition class="dropdown-content">
<div class="dropdown-item" @click="selectedNode.node_type = 'memo'; saveNodeType(); open = false">
<span class="dropdown-item-icon">📝</span>
<span>메모</span>
</div>
<div class="dropdown-item" @click="selectedNode.node_type = 'folder'; saveNodeType(); open = false">
<span class="dropdown-item-icon">📁</span>
<span>폴더</span>
</div>
<div class="dropdown-item" @click="selectedNode.node_type = 'chapter'; saveNodeType(); open = false">
<span class="dropdown-item-icon">📖</span>
<span>챕터</span>
</div>
<div class="dropdown-item" @click="selectedNode.node_type = 'character'; saveNodeType(); open = false">
<span class="dropdown-item-icon">👤</span>
<span>캐릭터</span>
</div>
<div class="dropdown-item" @click="selectedNode.node_type = 'plot'; saveNodeType(); open = false">
<span class="dropdown-item-icon">📋</span>
<span>플롯</span>
</div>
</div>
</div>
</div>
<!-- 상태 선택 -->
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">상태</label>
<div class="custom-dropdown" x-data="{ open: false }" @click.away="open = false">
<button
class="dropdown-button text-sm"
:class="{ 'active': open }"
@click="open = !open"
>
<span class="flex items-center">
<span class="dropdown-item-icon" x-text="getStatusIcon(selectedNode.status)"></span>
<span x-text="getStatusLabel(selectedNode.status)"></span>
</span>
<i class="fas fa-chevron-down transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<div x-show="open" x-transition class="dropdown-content">
<div class="dropdown-item" @click="selectedNode.status = 'draft'; saveNodeStatus(); open = false">
<span class="dropdown-item-icon">📝</span>
<span>초안</span>
</div>
<div class="dropdown-item" @click="selectedNode.status = 'writing'; saveNodeStatus(); open = false">
<span class="dropdown-item-icon">✍️</span>
<span>작성중</span>
</div>
<div class="dropdown-item" @click="selectedNode.status = 'review'; saveNodeStatus(); open = false">
<span class="dropdown-item-icon">👀</span>
<span>검토중</span>
</div>
<div class="dropdown-item" @click="selectedNode.status = 'complete'; saveNodeStatus(); open = false">
<span class="dropdown-item-icon"></span>
<span>완료</span>
</div>
</div>
</div>
</div>
</div>
<!-- 오른쪽: 저장과 삭제 -->
<div class="flex flex-col space-y-4">
<!-- 저장 버튼 (타입과 같은 높이) -->
<div class="flex flex-col">
<div class="h-4 mb-1"></div> <!-- 라벨 높이만큼 여백 -->
<button
@click="saveNode()"
class="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors duration-200 flex items-center justify-center whitespace-nowrap min-h-[34px]"
>
<i class="fas fa-save mr-2"></i>저장
</button>
</div>
<!-- 삭제 버튼 (상태와 같은 높이) -->
<div class="flex flex-col">
<div class="h-4 mb-1"></div> <!-- 라벨 높이만큼 여백 -->
<button
@click="deleteNode(selectedNode.id)"
class="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 transition-colors duration-200 flex items-center justify-center whitespace-nowrap min-h-[34px]"
>
<i class="fas fa-trash mr-2"></i>삭제
</button>
</div>
</div>
</div>
<!-- 단어 수 표시 -->
<div x-show="selectedNode?.word_count > 0" class="text-xs text-gray-500 mt-3">
<span x-text="`${selectedNode?.word_count || 0} 단어`"></span>
</div>
</div>
</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="notification.show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform translate-x-full"
x-transition:enter-end="opacity-100 transform translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform translate-x-0"
x-transition:leave-end="opacity-0 transform translate-x-full"
class="fixed top-4 right-4 z-50 max-w-sm">
<div class="rounded-lg shadow-lg border p-4"
:class="{
'bg-green-50 border-green-200 text-green-800': notification.type === 'success',
'bg-red-50 border-red-200 text-red-800': notification.type === 'error',
'bg-blue-50 border-blue-200 text-blue-800': notification.type === 'info'
}">
<div class="flex items-center">
<i :class="{
'fas fa-check-circle text-green-600': notification.type === 'success',
'fas fa-exclamation-circle text-red-600': notification.type === 'error',
'fas fa-info-circle text-blue-600': notification.type === 'info'
}" class="mr-2"></i>
<span x-text="notification.message"></span>
<button @click="notification.show = false" class="ml-auto text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</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="showMobileEditModal && selectedNode"
class="lg:hidden fixed inset-0 bg-black bg-opacity-50 flex items-end justify-center z-50"
@click.self="showMobileEditModal = false">
<div class="bg-white rounded-t-xl w-full max-h-[80vh] overflow-y-auto">
<!-- 모달 헤더 -->
<div class="flex items-center justify-between p-4 border-b sticky top-0 bg-white">
<h3 class="text-lg font-semibold">노드 편집</h3>
<button @click="showMobileEditModal = false" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 모달 내용 (데스크톱 편집 패널과 동일) -->
<div class="p-4" x-show="selectedNode">
<!-- 노드 제목 -->
<div class="mb-4">
<input
type="text"
x-model="selectedNode.title"
@blur="saveNodeTitle()"
class="text-lg font-semibold bg-transparent border-b-2 border-gray-200 focus:outline-none focus:border-blue-500 w-full pb-2"
placeholder="노드 제목을 입력하세요"
>
</div>
<!-- 타입/상태와 저장/삭제를 좌우로 배치 -->
<div class="flex items-start gap-4 mb-4">
<!-- 왼쪽: 타입과 상태 -->
<div class="flex-1 space-y-4">
<!-- 타입 선택 -->
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">타입</label>
<div class="custom-dropdown" x-data="{ open: false }" @click.away="open = false">
<button
class="dropdown-button text-sm"
:class="{ 'active': open }"
@click="open = !open"
>
<span class="flex items-center">
<span class="dropdown-item-icon" x-text="getNodeIcon(selectedNode.node_type)"></span>
<span x-text="getNodeTypeLabel(selectedNode.node_type)"></span>
</span>
<i class="fas fa-chevron-down transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<div x-show="open" x-transition class="dropdown-content">
<div class="dropdown-item" @click="selectedNode.node_type = 'memo'; saveNodeType(); open = false">
<span class="dropdown-item-icon">📝</span>
<span>메모</span>
</div>
<div class="dropdown-item" @click="selectedNode.node_type = 'folder'; saveNodeType(); open = false">
<span class="dropdown-item-icon">📁</span>
<span>폴더</span>
</div>
<div class="dropdown-item" @click="selectedNode.node_type = 'chapter'; saveNodeType(); open = false">
<span class="dropdown-item-icon">📖</span>
<span>챕터</span>
</div>
<div class="dropdown-item" @click="selectedNode.node_type = 'character'; saveNodeType(); open = false">
<span class="dropdown-item-icon">👤</span>
<span>캐릭터</span>
</div>
<div class="dropdown-item" @click="selectedNode.node_type = 'plot'; saveNodeType(); open = false">
<span class="dropdown-item-icon">📋</span>
<span>플롯</span>
</div>
</div>
</div>
</div>
<!-- 상태 선택 -->
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">상태</label>
<div class="custom-dropdown" x-data="{ open: false }" @click.away="open = false">
<button
class="dropdown-button text-sm"
:class="{ 'active': open }"
@click="open = !open"
>
<span class="flex items-center">
<span class="dropdown-item-icon" x-text="getStatusIcon(selectedNode.status)"></span>
<span x-text="getStatusLabel(selectedNode.status)"></span>
</span>
<i class="fas fa-chevron-down transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<div x-show="open" x-transition class="dropdown-content">
<div class="dropdown-item" @click="selectedNode.status = 'draft'; saveNodeStatus(); open = false">
<span class="dropdown-item-icon">📝</span>
<span>초안</span>
</div>
<div class="dropdown-item" @click="selectedNode.status = 'writing'; saveNodeStatus(); open = false">
<span class="dropdown-item-icon">✍️</span>
<span>작성중</span>
</div>
<div class="dropdown-item" @click="selectedNode.status = 'review'; saveNodeStatus(); open = false">
<span class="dropdown-item-icon">👀</span>
<span>검토중</span>
</div>
<div class="dropdown-item" @click="selectedNode.status = 'complete'; saveNodeStatus(); open = false">
<span class="dropdown-item-icon"></span>
<span>완료</span>
</div>
</div>
</div>
</div>
</div>
<!-- 오른쪽: 저장과 삭제 -->
<div class="flex flex-col space-y-4">
<!-- 저장 버튼 -->
<div class="flex flex-col">
<div class="h-4 mb-1"></div>
<button
@click="saveNode(); showMobileEditModal = false"
class="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors duration-200 flex items-center justify-center whitespace-nowrap min-h-[34px]"
>
<i class="fas fa-save mr-2"></i>저장
</button>
</div>
<!-- 삭제 버튼 -->
<div class="flex flex-col">
<div class="h-4 mb-1"></div>
<button
@click="deleteNode(selectedNode.id); showMobileEditModal = false"
class="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 transition-colors duration-200 flex items-center justify-center whitespace-nowrap min-h-[34px]"
>
<i class="fas fa-trash mr-2"></i>삭제
</button>
</div>
</div>
</div>
<!-- 에디터 영역 -->
<div class="border rounded-lg overflow-hidden">
<div class="h-64" id="mobile-editor-container"></div>
</div>
<!-- 단어 수 표시 -->
<div x-show="selectedNode?.word_count > 0" class="text-xs text-gray-500 mt-3 text-center">
<span x-text="`${selectedNode?.word_count || 0} 단어`"></span>
</div>
</div>
</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=2025012380');
console.log('✅ API 스크립트 로드 완료');
await loadScript('static/js/auth.js?v=2025012351');
console.log('✅ Auth 스크립트 로드 완료');
await loadScript('static/js/memo-tree.js?v=2025012359');
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>