주요 변경사항: - 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)으로 통일
1282 lines
57 KiB
HTML
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>
|