884 lines
		
	
	
		
			39 KiB
		
	
	
	
		
			HTML
		
	
	
	
	
	
			
		
		
	
	
			884 lines
		
	
	
		
			39 KiB
		
	
	
	
		
			HTML
		
	
	
	
	
	
| {{ define "base" }}
 | |
| <!DOCTYPE html>
 | |
| <html lang="en" class="dark">
 | |
| <head>
 | |
|     <meta charset="UTF-8">
 | |
|     <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | |
|     <title>{{.app_name}}</title>
 | |
|     <link rel="stylesheet" href="{{.prefix}}/static/tailwind.css">
 | |
|     <link rel="stylesheet" href="{{.prefix}}/static/styles.css">
 | |
|     <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
 | |
|     <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
 | |
|     <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/go.min.js"></script>
 | |
|     <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/python.min.js"></script>
 | |
|     <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/javascript.min.js"></script>
 | |
|     <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/markdown.min.js"></script>
 | |
|     <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/json.min.js"></script>
 | |
|     <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/yaml.min.js"></script>
 | |
|     <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/xml.min.js"></script>
 | |
|     <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/sql.min.js"></script>
 | |
|     <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/bash.min.js"></script>
 | |
|     <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/css.min.js"></script>
 | |
|     <!-- Mermaid for diagrams -->
 | |
|     <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
 | |
|     <script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js"></script>
 | |
|     <style>
 | |
|         /* Custom scrollbar for dark theme */
 | |
|         ::-webkit-scrollbar {
 | |
|             width: 8px;
 | |
|             height: 8px;
 | |
|         }
 | |
|         ::-webkit-scrollbar-track {
 | |
|             background: #1e293b;
 | |
|         }
 | |
|         ::-webkit-scrollbar-thumb {
 | |
|             background: #475569;
 | |
|             border-radius: 4px;
 | |
|         }
 | |
|         ::-webkit-scrollbar-thumb:hover {
 | |
|             background: #64748b;
 | |
|         }
 | |
| 
 | |
|         /* Content markdown styles */
 | |
|         .prose-dark {
 | |
|             color: #d1d5db;
 | |
|         }
 | |
|         .prose-dark h1, .prose-dark h2, .prose-dark h3, .prose-dark h4, .prose-dark h5, .prose-dark h6 {
 | |
|             color: white;
 | |
|         }
 | |
|         /* Headings sizing and spacing */
 | |
|         .prose-dark h1 { font-size: 1.875rem; line-height: 2.25rem; margin-top: 1.5rem; margin-bottom: 0.75rem; }
 | |
|         .prose-dark h2 { font-size: 1.5rem; line-height: 2rem; margin-top: 1.25rem; margin-bottom: 0.5rem; }
 | |
|         .prose-dark h3 { font-size: 1.25rem; line-height: 1.75rem; margin-top: 1rem; margin-bottom: 0.5rem; }
 | |
|         .prose-dark h4 { font-size: 1.125rem; line-height: 1.5rem; margin-top: 0.75rem; margin-bottom: 0.5rem; }
 | |
|         .prose-dark p { margin: 0.75rem 0; }
 | |
|         .prose-dark hr { border-color: #374151; margin: 1.25rem 0; }
 | |
| 
 | |
|         /* Lists */
 | |
|         .prose-dark ul { list-style-type: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
 | |
|         .prose-dark ol { list-style-type: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
 | |
|         .prose-dark li { margin: 0.25rem 0; }
 | |
|         .prose-dark li > ul { list-style-type: circle; }
 | |
|         .prose-dark li > ol { list-style-type: lower-alpha; }
 | |
| 
 | |
|         .prose-dark a {
 | |
|             color: #60a5fa;
 | |
|         }
 | |
|         .prose-dark a:hover {
 | |
|             color: #93c5fd;
 | |
|         }
 | |
|         .prose-dark code {
 | |
|             background-color: #1f2937;
 | |
|             color: #10b981;
 | |
|             padding: 0.125rem 0.25rem;
 | |
|             border-radius: 0.25rem;
 | |
|         }
 | |
|         .prose-dark pre {
 | |
|             background-color: #111827;
 | |
|             border: 1px solid #374151;
 | |
|             overflow: auto;
 | |
|         }
 | |
|         .prose-dark pre code { background: transparent; color: inherit; padding: 0; }
 | |
|         .prose-dark blockquote {
 | |
|             border-left: 4px solid #3b82f6;
 | |
|             background-color: #1f2937;
 | |
|             padding-left: 1rem;
 | |
|             padding-top: 0.5rem;
 | |
|             padding-bottom: 0.5rem;
 | |
|             margin: 1rem 0;
 | |
|             font-style: italic;
 | |
|         }
 | |
|         .prose-dark table {
 | |
|             border-collapse: collapse;
 | |
|             border: 1px solid #4b5563;
 | |
|         }
 | |
|         .prose-dark th, .prose-dark td {
 | |
|             border: 1px solid #4b5563;
 | |
|             padding: 0.75rem;
 | |
|         }
 | |
|         .prose-dark th {
 | |
|             background-color: #1f2937;
 | |
|             font-weight: 600;
 | |
|         }
 | |
|         .prose-dark img {
 | |
|             max-width: 100%;
 | |
|             height: auto;
 | |
|             border-radius: 0.5rem;
 | |
|             box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
 | |
|             cursor: pointer;
 | |
|         }
 | |
| 
 | |
|         /* Sidebar styles */
 | |
|         .sidebar-item {
 | |
|             display: flex;
 | |
|             align-items: center;
 | |
|             padding: 0.5rem 0.75rem;
 | |
|             border-radius: 0.5rem;
 | |
|             transition: background-color 0.2s;
 | |
|             cursor: pointer;
 | |
|         }
 | |
|         .sidebar-item:hover {
 | |
|             background-color: #374151;
 | |
|         }
 | |
|         .sidebar-item.active {
 | |
|             background-color: #2563eb;
 | |
|             color: white;
 | |
|         }
 | |
| 
 | |
|         /* Custom button styles */
 | |
|         .btn-primary {
 | |
|             background-color: #2563eb;
 | |
|             color: white;
 | |
|             font-weight: 500;
 | |
|             padding: 0.5rem 1rem;
 | |
|             border-radius: 0.5rem;
 | |
|             transition: background-color 0.2s;
 | |
|             text-decoration: none;
 | |
|             display: inline-block;
 | |
|         }
 | |
|         .btn-primary:hover {
 | |
|             background-color: #1d4ed8;
 | |
|         }
 | |
|         .btn-secondary {
 | |
|             background-color: #4b5563;
 | |
|             color: white;
 | |
|             font-weight: 500;
 | |
|             padding: 0.5rem 1rem;
 | |
|             border-radius: 0.5rem;
 | |
|             transition: background-color 0.2s;
 | |
|             text-decoration: none;
 | |
|             display: inline-block;
 | |
|         }
 | |
|         .btn-secondary:hover {
 | |
|             background-color: #374151;
 | |
|         }
 | |
|         .btn-danger {
 | |
|             background-color: #dc2626;
 | |
|             color: white;
 | |
|             font-weight: 500;
 | |
|             padding: 0.5rem 1rem;
 | |
|             border-radius: 0.5rem;
 | |
|             transition: background-color 0.2s;
 | |
|             text-decoration: none;
 | |
|             display: inline-block;
 | |
|         }
 | |
|         .btn-danger:hover {
 | |
|             background-color: #b91c1c;
 | |
|         }
 | |
| 
 | |
|         /* Sidebar collapse behavior */
 | |
|         #sidebar.collapsed {
 | |
|             width: 2.5rem !important; /* 10px * 10 = 2.5rem */
 | |
|             border-right: none;
 | |
|         }
 | |
|         #sidebar.collapsed .sidebar-title,
 | |
|         #sidebar.collapsed .sidebar-actions,
 | |
|         #sidebar.collapsed .sidebar-content {
 | |
|             display: none;
 | |
|         }
 | |
|         #sidebar .toggle-btn {
 | |
|             background: transparent;
 | |
|             color: #cbd5e1;
 | |
|             padding: 0.25rem;
 | |
|             border-radius: 0.375rem;
 | |
|         }
 | |
|         #sidebar .toggle-btn:hover {
 | |
|             background: #374151;
 | |
|         }
 | |
| 
 | |
|         /* Modal styles */
 | |
|         .modal-overlay {
 | |
|             position: fixed;
 | |
|             top: 0;
 | |
|             left: 0;
 | |
|             right: 0;
 | |
|             bottom: 0;
 | |
|             background-color: rgba(0, 0, 0, 0.5);
 | |
|             display: flex;
 | |
|             align-items: center;
 | |
|             justify-content: center;
 | |
|             z-index: 50;
 | |
|         }
 | |
|         .modal-content {
 | |
|             background-color: #1f2937;
 | |
|             border-radius: 0.5rem;
 | |
|             padding: 1.5rem;
 | |
|             max-width: 32rem;
 | |
|             width: 100%;
 | |
|             margin: 1rem;
 | |
|             max-height: 24rem;
 | |
|             overflow-y: auto;
 | |
|         }
 | |
| 
 | |
|         /* Editor styles */
 | |
|         .editor-textarea {
 | |
|             width: 100%;
 | |
|             min-height: 24rem;
 | |
|             background-color: #1f2937;
 | |
|             color: #d1d5db;
 | |
|             border: 1px solid #4b5563;
 | |
|             border-radius: 0.5rem;
 | |
|             padding: 1rem;
 | |
|             font-family: monospace;
 | |
|             font-size: 0.875rem;
 | |
|             resize: vertical;
 | |
|         }
 | |
|         .editor-textarea:focus {
 | |
|             outline: none;
 | |
|             border-color: #3b82f6;
 | |
|             box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
 | |
|         }
 | |
| 
 | |
|         /* Form input styles */
 | |
|         .form-input, .form-textarea {
 | |
|             width: 100%;
 | |
|             background-color: #374151;
 | |
|             border: 1px solid #4b5563;
 | |
|             border-radius: 0.5rem;
 | |
|             padding: 0.5rem 0.75rem;
 | |
|             color: white;
 | |
|         }
 | |
|         .form-input:focus, .form-textarea:focus {
 | |
|             outline: none;
 | |
|             border-color: #3b82f6;
 | |
|             box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
 | |
|         }
 | |
| 
 | |
|         .hidden {
 | |
|             display: none;
 | |
|         }
 | |
| 
 | |
|         .rotate-90 {
 | |
|             transform: rotate(90deg);
 | |
|         }
 | |
|     </style>
 | |
|     <script>
 | |
|     // Initialize Mermaid (dark theme) and run on page load
 | |
|     document.addEventListener('DOMContentLoaded', function() {
 | |
|         if (window.mermaid) {
 | |
|             try {
 | |
|                 window.mermaid.initialize({ startOnLoad: false, theme: 'dark' });
 | |
|                 const containers = document.querySelectorAll('.mermaid');
 | |
|                 if (containers.length) {
 | |
|                     window.mermaid.run({ querySelector: '.mermaid' });
 | |
|                 }
 | |
|             } catch (e) {}
 | |
|         }
 | |
|     });
 | |
|     </script>
 | |
| </head>
 | |
| <body class="bg-slate-900 text-gray-300 min-h-screen">
 | |
|     <div class="flex h-screen">
 | |
|         <!-- Sidebar -->
 | |
|         {{if not .NoSidebar}}
 | |
|         <div id="sidebar" class="w-80 bg-slate-800 border-r border-gray-700 flex flex-col">
 | |
|             <!-- Header -->
 | |
|             <div class="p-4 border-b border-gray-700">
 | |
|                 <div class="flex items-center justify-between">
 | |
|                     <a href="{{url "/"}}" class="sidebar-title text-xl font-bold text-white hover:text-blue-300" title="Home" aria-label="Home">
 | |
|                         <i class="fas fa-house"></i>
 | |
|                         <span class="sr-only">{{.app_name}}</span>
 | |
|                     </a>
 | |
|                     <div class="flex items-center space-x-2 items-center">
 | |
|                         <div class="sidebar-actions flex items-center space-x-3">
 | |
|                             <button id="open-search" class="text-gray-400 hover:text-white transition-colors" title="Search" aria-label="Search">
 | |
|                                 <i class="fas fa-magnifying-glass"></i>
 | |
|                             </button>
 | |
|                             <button id="expand-all" class="text-gray-400 hover:text-white transition-colors" title="Expand all" aria-label="Expand all">
 | |
|                                 <i class="fas fa-folder-open"></i>
 | |
|                             </button>
 | |
|                             <button id="collapse-all" class="text-gray-400 hover:text-white transition-colors" title="Collapse all" aria-label="Collapse all">
 | |
|                                 <i class="fas fa-folder"></i>
 | |
|                             </button>
 | |
|                             {{if .Authenticated}}
 | |
|                                 {{if .IsAdmin}}
 | |
|                                     <a href="{{url "/editor/admin"}}" class="text-gray-400 hover:text-white transition-colors" title="Admin">
 | |
|                                         <i class="fas fa-user-shield"></i>
 | |
|                                     </a>
 | |
|                                 {{end}}
 | |
|                                 <a href="{{url "/editor/profile"}}" class="text-gray-400 hover:text-white transition-colors" title="Profile">
 | |
|                                     <i class="fas fa-user"></i>
 | |
|                                 </a>
 | |
|                                 <a href="{{url "/editor/settings"}}" class="text-gray-400 hover:text-white transition-colors" title="Settings">
 | |
|                                     <i class="fas fa-gear"></i>
 | |
|                                 </a>
 | |
|                             {{end}}
 | |
|                             {{if .Authenticated}}
 | |
|                                 <button id="logout-btn" class="text-gray-400 hover:text-white transition-colors" title="Logout">
 | |
|                                     <i class="fas fa-right-from-bracket"></i>
 | |
|                                 </button>
 | |
|                             {{else}}
 | |
|                                 <a href="{{url "/editor/login"}}" class="text-gray-400 hover:text-white transition-colors" title="Login">
 | |
|                                     <i class="fas fa-right-to-bracket"></i>
 | |
|                                 </a>
 | |
|                             {{end}}
 | |
|                         </div>
 | |
|                         <button id="sidebar-toggle" class="toggle-btn" title="Toggle sidebar" aria-label="Toggle sidebar">
 | |
|                             <i id="sidebar-toggle-icon" class="fas fa-chevron-left"></i>
 | |
|                         </button>
 | |
|                     </div>
 | |
|                 </div>
 | |
|             </div>
 | |
| 
 | |
|             <!-- Navigation -->
 | |
|             <div class="sidebar-content px-4 py-4">
 | |
|                 {{if .Authenticated}}
 | |
|                     <a href="{{url "/editor/create"}}" class="btn-primary text-sm w-full text-center">
 | |
|                         <i class="fas fa-plus mr-2"></i>New Note
 | |
|                     </a>
 | |
|                 {{end}}
 | |
|             </div>
 | |
| 
 | |
|             <!-- File Tree -->
 | |
|             <div id="sidebar-tree" class="sidebar-content flex-1 overflow-y-auto px-4 pb-4">
 | |
|                 {{if .notes_tree}}
 | |
|                     {{/* Render only children of the root to hide root folder label */}}
 | |
|                     {{range .notes_tree.Children}}
 | |
|                         {{template "tree_node" dict "node" . "active_path" $.active_path "current_note" $.current_note}}
 | |
|                     {{end}}
 | |
|                 {{end}}
 | |
|             </div>
 | |
|         </div>
 | |
|         {{end}}
 | |
| 
 | |
|         <!-- Main Content -->
 | |
|         <div class="flex-1 flex flex-col overflow-hidden">
 | |
|             <!-- Breadcrumbs -->
 | |
|             {{if .breadcrumbs}}
 | |
|             <div class="bg-slate-800 border-b border-gray-700 px-6 py-3">
 | |
|                 <nav class="flex items-center flex-wrap gap-1.5 text-sm">
 | |
|                     {{range $i, $crumb := .breadcrumbs}}
 | |
|                         {{if $i}}<i class="fas fa-chevron-right text-gray-500 text-xs mx-1"></i>{{end}}
 | |
|                         {{if $crumb.URL}}
 | |
|                             <a href="{{url $crumb.URL}}" class="inline-flex items-center px-2.5 py-1 rounded-md border border-slate-600 bg-slate-700/40 text-blue-300 hover:bg-slate-700 hover:text-blue-200 transition-colors" aria-label="Breadcrumb: {{$crumb.Name}}">
 | |
|                                 {{if and (eq $i 0) (eq $crumb.Name "/")}}<i class="fas fa-folder-tree mr-1.5"></i>{{end}}
 | |
|                                 <span class="leading-none">{{$crumb.Name}}</span>
 | |
|                             </a>
 | |
|                         {{else}}
 | |
|                             <span class="inline-flex items-center px-2.5 py-1 rounded-md border border-slate-600 bg-slate-700/60 text-gray-200">
 | |
|                                 <span class="leading-none">{{$crumb.Name}}</span>
 | |
|                             </span>
 | |
|                         {{end}}
 | |
|                     {{end}}
 | |
|                 </nav>
 | |
|             </div>
 | |
|             {{end}}
 | |
| 
 | |
|             <!-- Content Area -->
 | |
|             <div class="flex-1 overflow-y-auto">
 | |
|                 {{if eq .Page "folder"}}
 | |
|                     {{template "folder_content" .}}
 | |
|                 {{else if eq .Page "note"}}
 | |
|                     {{template "note_content" .}}
 | |
|                 {{else if eq .Page "view_text"}}
 | |
|                     {{template "view_text_content" .}}
 | |
|                 {{else if eq .Page "edit_text"}}
 | |
|                     {{template "edit_text_content" .}}
 | |
|                 {{else if eq .Page "create"}}
 | |
|                     {{template "create_content" .}}
 | |
|                 {{else if eq .Page "edit"}}
 | |
|                     {{template "edit_content" .}}
 | |
|                 {{else if eq .Page "settings"}}
 | |
|                     {{template "settings_content" .}}
 | |
|                 {{else if eq .Page "admin"}}
 | |
|                     {{template "admin_content" .}}
 | |
|                 {{else if eq .Page "admin_logs"}}
 | |
|                     {{template "admin_logs_content" .}}
 | |
|                 {{else if eq .Page "profile"}}
 | |
|                     {{template "profile_content" .}}
 | |
|                 {{else if eq .Page "error"}}
 | |
|                     {{template "error_content" .}}
 | |
|                 {{else if eq .Page "login"}}
 | |
|                     {{template "login_content" .}}
 | |
|                 {{else if eq .Page "mfa"}}
 | |
|                     {{template "mfa_content" .}}
 | |
|                 {{else if eq .Page "mfa_setup"}}
 | |
|                     {{template "mfa_setup_content" .}}
 | |
|                 {{end}}
 | |
|             </div>
 | |
|         </div>
 | |
|     </div>
 | |
| 
 | |
|     <!-- Search Modal -->
 | |
|     <div id="search-modal" class="modal-overlay hidden">
 | |
|         <div class="modal-content w-full max-w-3xl">
 | |
|             <div class="flex items-center mb-3">
 | |
|                 <div class="relative flex-1">
 | |
|                     <i class="fas fa-magnifying-glass absolute left-3 top-3 text-gray-400"></i>
 | |
|                     <input id="search-input" type="text" placeholder="Search notes..." class="form-input pl-9 w-full" />
 | |
|                 </div>
 | |
|                 <button id="clear-search" class="ml-2 px-3 py-2 rounded bg-red-600 hover:bg-red-700 text-white" title="Clear">
 | |
|                     Clear
 | |
|                 </button>
 | |
|                 <button id="close-search" class="btn-secondary ml-2" title="Close">
 | |
|                     <i class="fas fa-xmark"></i>
 | |
|                 </button>
 | |
|             </div>
 | |
|             <div id="search-status" class="text-sm text-gray-400 mb-2"></div>
 | |
|             <div id="search-results" class="space-y-3"></div>
 | |
|         </div>
 | |
|     </div>
 | |
| 
 | |
|     <!-- Scripts -->
 | |
|     <script>
 | |
|         // Initialize syntax highlighting
 | |
|         // Avoid warnings and double-highlighting: skip elements already highlighted by Chroma
 | |
|         if (window.hljs) {
 | |
|             try {
 | |
|                 // Suppress unescaped HTML warnings from hljs
 | |
|                 window.hljs.configure({ ignoreUnescapedHTML: true });
 | |
|                 // Only highlight code blocks that are not inside a .chroma container
 | |
|                 document.querySelectorAll('pre code').forEach(function (el) {
 | |
|                     if (!el.closest('.chroma')) {
 | |
|                         window.hljs.highlightElement(el);
 | |
|                     }
 | |
|                 });
 | |
|             } catch (e) { /* ignore */ }
 | |
|         }
 | |
| 
 | |
|         // Base URL prefix from server
 | |
|         window.BASE = '{{base}}';
 | |
|         window.prefix = function(p) {
 | |
|             var b = window.BASE || '';
 | |
|             if (!b) {
 | |
|                 if (!p) return '';
 | |
|                 return p[0] === '/' ? p : '/' + p;
 | |
|             }
 | |
|             if (!p || p === '/') return b + '/';
 | |
|             return p[0] === '/' ? (b + p) : (b + '/' + p);
 | |
|         };
 | |
| 
 | |
|         // Tree functionality with persisted expanded state
 | |
|         const LS_KEY_EXPANDED = 'tree:expanded';
 | |
|         function getExpandedSet() {
 | |
|             try {
 | |
|                 const raw = localStorage.getItem(LS_KEY_EXPANDED);
 | |
|                 const arr = raw ? JSON.parse(raw) : [];
 | |
|                 return new Set(Array.isArray(arr) ? arr : []);
 | |
|             } catch { return new Set(); }
 | |
|         }
 | |
|         function saveExpandedSet(set) {
 | |
|             try { localStorage.setItem(LS_KEY_EXPANDED, JSON.stringify(Array.from(set))); } catch {}
 | |
|         }
 | |
|         function setExpanded(toggleEl, expand) {
 | |
|             const children = toggleEl.nextElementSibling;
 | |
|             const chevron = toggleEl.querySelector('.tree-chevron');
 | |
|             if (!children || !children.classList.contains('tree-children')) return;
 | |
|             const isHidden = children.classList.contains('hidden');
 | |
|             if (expand && isHidden) children.classList.remove('hidden');
 | |
|             if (!expand && !isHidden) children.classList.add('hidden');
 | |
|             if (chevron) chevron.classList.toggle('rotate-90', expand);
 | |
|         }
 | |
|         function applyExpandedState() {
 | |
|             const expanded = getExpandedSet();
 | |
|             document.querySelectorAll('.tree-toggle').forEach(t => {
 | |
|                 const path = t.getAttribute('data-path') || '';
 | |
|                 const shouldExpand = expanded.has(path);
 | |
|                 setExpanded(t, shouldExpand);
 | |
|             });
 | |
|         }
 | |
|         document.addEventListener('click', function(e) {
 | |
|             if (e.target.closest('.tree-toggle')) {
 | |
|                 const toggle = e.target.closest('.tree-toggle');
 | |
|                 const children = toggle.nextElementSibling;
 | |
|                 const chevron = toggle.querySelector('.tree-chevron');
 | |
|                 if (children && children.classList.contains('tree-children')) {
 | |
|                     const expanded = getExpandedSet();
 | |
|                     const path = toggle.getAttribute('data-path') || '';
 | |
|                     const willExpand = children.classList.contains('hidden');
 | |
|                     children.classList.toggle('hidden');
 | |
|                     if (chevron) chevron.classList.toggle('rotate-90');
 | |
|                     if (willExpand) expanded.add(path); else expanded.delete(path);
 | |
|                     saveExpandedSet(expanded);
 | |
|                 }
 | |
|             }
 | |
|         });
 | |
| 
 | |
|         // Right-click on folder in sidebar opens folder view in current page
 | |
|         document.addEventListener('contextmenu', function(e) {
 | |
|             const toggle = e.target.closest('.tree-toggle');
 | |
|             if (!toggle) return;
 | |
|             e.preventDefault();
 | |
|             const path = toggle.getAttribute('data-path') || '';
 | |
|             const url = window.prefix('/folder/' + path);
 | |
|             window.location.href = url;
 | |
|         });
 | |
| 
 | |
|         // Auto-expand active path
 | |
|         function expandActivePath() {
 | |
|             const activeItem = document.querySelector('.sidebar-item.active');
 | |
|             if (activeItem) {
 | |
|                 let parent = activeItem.parentElement;
 | |
|                 while (parent) {
 | |
|                     if (parent.classList.contains('tree-children')) {
 | |
|                         parent.classList.remove('hidden');
 | |
|                         const toggle = parent.previousElementSibling;
 | |
|                         if (toggle && toggle.classList.contains('tree-toggle')) {
 | |
|                             const chevron = toggle.querySelector('.tree-chevron');
 | |
|                             if (chevron) {
 | |
|                                 chevron.classList.add('rotate-90');
 | |
|                             }
 | |
|                             // persist this expanded state
 | |
|                             const expanded = getExpandedSet();
 | |
|                             const path = toggle.getAttribute('data-path') || '';
 | |
|                             expanded.add(path);
 | |
|                             saveExpandedSet(expanded);
 | |
|                         }
 | |
|                     }
 | |
|                     parent = parent.parentElement;
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // Notification system
 | |
|         function showNotification(message, type = 'info', duration = 3000) {
 | |
|             const notification = document.createElement('div');
 | |
|             notification.className = `fixed top-4 right-4 z-50 px-6 py-3 rounded-lg shadow-lg max-w-sm`;
 | |
|             
 | |
|             if (type === 'success') {
 | |
|                 notification.className += ' bg-green-600 text-white';
 | |
|             } else if (type === 'error') {
 | |
|                 notification.className += ' bg-red-600 text-white';
 | |
|             } else {
 | |
|                 notification.className += ' bg-blue-600 text-white';
 | |
|             }
 | |
|             
 | |
|             notification.innerHTML = `
 | |
|                 <div class="flex items-center justify-between">
 | |
|                     <span>${message}</span>
 | |
|                     <button class="ml-4 text-white hover:text-gray-300" onclick="this.closest('div').remove()">
 | |
|                         <i class="fas fa-times"></i>
 | |
|                     </button>
 | |
|                 </div>
 | |
|             `;
 | |
|             
 | |
|             document.body.appendChild(notification);
 | |
|             
 | |
|             setTimeout(() => {
 | |
|                 if (notification.parentElement) {
 | |
|                     notification.remove();
 | |
|                 }
 | |
|             }, duration);
 | |
|         }
 | |
| 
 | |
|         // Sidebar toggle persistence
 | |
|         function applySidebarState(collapsed) {
 | |
|             const sidebar = document.getElementById('sidebar');
 | |
|             const icon = document.getElementById('sidebar-toggle-icon');
 | |
|             if (!sidebar || !icon) return;
 | |
|             if (collapsed) {
 | |
|                 sidebar.classList.add('collapsed');
 | |
|                 icon.classList.remove('fa-chevron-left');
 | |
|                 icon.classList.add('fa-chevron-right');
 | |
|             } else {
 | |
|                 sidebar.classList.remove('collapsed');
 | |
|                 icon.classList.remove('fa-chevron-right');
 | |
|                 icon.classList.add('fa-chevron-left');
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         document.addEventListener('DOMContentLoaded', function() {
 | |
|             // Restore sidebar state
 | |
|             const collapsed = localStorage.getItem('sidebarCollapsed') === 'true';
 | |
|             applySidebarState(collapsed);
 | |
| 
 | |
|             // Wire toggle button
 | |
|             const toggleBtn = document.getElementById('sidebar-toggle');
 | |
|             if (toggleBtn) {
 | |
|                 toggleBtn.addEventListener('click', function() {
 | |
|                     const currentlyCollapsed = document.getElementById('sidebar').classList.contains('collapsed');
 | |
|                     const next = !currentlyCollapsed;
 | |
|                     localStorage.setItem('sidebarCollapsed', String(next));
 | |
|                     applySidebarState(next);
 | |
|                 });
 | |
|             }
 | |
| 
 | |
|             // Apply persisted expanded folders, then ensure active path is expanded
 | |
|             applyExpandedState();
 | |
|             expandActivePath();
 | |
| 
 | |
|             // Wire expand/collapse all
 | |
|             const expandAllBtn = document.getElementById('expand-all');
 | |
|             const collapseAllBtn = document.getElementById('collapse-all');
 | |
|             if (expandAllBtn) expandAllBtn.addEventListener('click', function() {
 | |
|                 const expanded = getExpandedSet();
 | |
|                 document.querySelectorAll('.tree-toggle').forEach(t => {
 | |
|                     const path = t.getAttribute('data-path') || '';
 | |
|                     setExpanded(t, true);
 | |
|                     expanded.add(path);
 | |
|                 });
 | |
|                 saveExpandedSet(expanded);
 | |
|             });
 | |
|             if (collapseAllBtn) collapseAllBtn.addEventListener('click', function() {
 | |
|                 const expanded = new Set();
 | |
|                 document.querySelectorAll('.tree-toggle').forEach(t => setExpanded(t, false));
 | |
|                 saveExpandedSet(expanded);
 | |
|             });
 | |
| 
 | |
|             // Sidebar tree fallback: if server didn't render any tree nodes (e.g., error pages), fetch and render via API
 | |
|             (function ensureSidebarTree() {
 | |
|                 const container = document.getElementById('sidebar-tree');
 | |
|                 if (!container) return;
 | |
|                 const hasTree = container.querySelector('.tree-node, .sidebar-item');
 | |
|                 if (hasTree) return; // already populated
 | |
| 
 | |
|                 // Fetch tree
 | |
|                 fetch(window.prefix('/api/tree'))
 | |
|                     .then(r => r.json())
 | |
|                     .then(data => {
 | |
|                         if (!data || !Array.isArray(data.children)) return;
 | |
|                         // Clear container
 | |
|                         container.innerHTML = '';
 | |
|                         data.children.forEach(child => {
 | |
|                             container.appendChild(renderTreeNode(child));
 | |
|                         });
 | |
|                         // Re-apply expanded state and active path if any
 | |
|                         applyExpandedState();
 | |
|                         expandActivePath();
 | |
|                     })
 | |
|                     .catch(() => {/* ignore */});
 | |
| 
 | |
|                 function renderTreeNode(node) {
 | |
|                     const wrapper = document.createElement('div');
 | |
|                     wrapper.className = 'tree-node';
 | |
|                     if (node.children && node.children.length) {
 | |
|                         const toggle = document.createElement('div');
 | |
|                         toggle.className = 'tree-toggle flex items-center py-1 hover:bg-gray-700 rounded px-2 cursor-pointer';
 | |
|                         toggle.setAttribute('data-path', node.path || '');
 | |
|                         toggle.innerHTML = '<i class="fas fa-chevron-right transform transition-transform duration-200 mr-2 text-xs tree-chevron"></i>' +
 | |
|                                           '<span class="mr-2">📁</span>' +
 | |
|                                           `<span class="flex-1">${escapeHTML(node.name || '')}</span>`;
 | |
|                         const children = document.createElement('div');
 | |
|                         children.className = 'tree-children ml-4 hidden';
 | |
|                         (node.children || []).forEach(ch => {
 | |
|                             children.appendChild(renderTreeNode(ch));
 | |
|                         });
 | |
|                         wrapper.appendChild(toggle);
 | |
|                         wrapper.appendChild(children);
 | |
|                     } else {
 | |
|                         let href = window.prefix('/view_text/' + (node.path || ''));
 | |
|                         let icon = '📄';
 | |
|                         if ((node.type || '').toLowerCase() === 'md') {
 | |
|                             href = window.prefix('/note/' + (node.path || ''));
 | |
|                             icon = '📝';
 | |
|                         } else if ((node.type || '').toLowerCase() === 'image') {
 | |
|                             href = window.prefix('/serve_attached_image/' + (node.path || ''));
 | |
|                             icon = '🖼️';
 | |
|                         }
 | |
|                         const a = document.createElement('a');
 | |
|                         a.href = href;
 | |
|                         a.className = 'sidebar-item';
 | |
|                         a.innerHTML = `<span class="mr-2">${icon}</span><span>${escapeHTML(node.name || '')}</span>`;
 | |
|                         if ((node.type || '').toLowerCase() === 'image') a.target = '_blank';
 | |
|                         wrapper.appendChild(a);
 | |
|                     }
 | |
|                     return wrapper;
 | |
|                 }
 | |
|                 function escapeHTML(str) {
 | |
|                     return String(str).replace(/[&<>"']/g, s => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[s]));
 | |
|                 }
 | |
|             })();
 | |
| 
 | |
|             // Search modal wiring
 | |
|             const searchModal = document.getElementById('search-modal');
 | |
|             const openSearchBtn = document.getElementById('open-search');
 | |
|             const closeSearchBtn = document.getElementById('close-search');
 | |
|             const clearSearchBtn = document.getElementById('clear-search');
 | |
|             const searchInput = document.getElementById('search-input');
 | |
|             const searchResults = document.getElementById('search-results');
 | |
|             const searchStatus = document.getElementById('search-status');
 | |
| 
 | |
|             const LS_KEY_QUERY = 'globalSearch:query';
 | |
| 
 | |
|             function openSearch() {
 | |
|                 if (!searchModal) return;
 | |
|                 searchModal.classList.remove('hidden');
 | |
|                 setTimeout(() => searchInput && searchInput.focus(), 0);
 | |
|                 const lastQuery = localStorage.getItem(LS_KEY_QUERY) || '';
 | |
|                 if (searchInput) {
 | |
|                     searchInput.value = lastQuery;
 | |
|                     if (lastQuery) doSearch(lastQuery);
 | |
|                 }
 | |
|             }
 | |
|             function closeSearch() {
 | |
|                 if (!searchModal) return;
 | |
|                 searchModal.classList.add('hidden');
 | |
|             }
 | |
|             function clearSearch() {
 | |
|                 if (!searchInput) return;
 | |
|                 searchInput.value = '';
 | |
|                 localStorage.removeItem(LS_KEY_QUERY);
 | |
|                 renderResults({ query: '', results: [] });
 | |
|             }
 | |
| 
 | |
|             let debounceTimer = null;
 | |
|             function debounce(fn, delay) {
 | |
|                 return function(...args) {
 | |
|                     clearTimeout(debounceTimer);
 | |
|                     debounceTimer = setTimeout(() => fn.apply(this, args), delay);
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             function escapeHTML(str) {
 | |
|                 return str.replace(/[&<>"']/g, s => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[s]));
 | |
|             }
 | |
| 
 | |
|             function renderResults(data) {
 | |
|                 if (!searchResults || !searchStatus) return;
 | |
|                 searchResults.innerHTML = '';
 | |
|                 if (!data || !data.results) {
 | |
|                     searchStatus.textContent = '';
 | |
|                     return;
 | |
|                 }
 | |
|                 searchStatus.textContent = `${data.results.length} result(s)`;
 | |
|                 data.results.forEach(r => {
 | |
|                     const item = document.createElement('div');
 | |
|                     item.className = 'p-3 bg-slate-700 rounded border border-slate-600';
 | |
|                     const title = document.createElement('div');
 | |
|                     title.className = 'flex items-center justify-between text-sm text-blue-300 hover:text-blue-200 cursor-pointer';
 | |
|                     title.innerHTML = `<span><i class="fas ${r.type === 'md' ? 'fa-file-lines' : 'fa-file'} mr-2"></i>${escapeHTML(r.path)}</span>`;
 | |
|                     title.addEventListener('click', () => {
 | |
|                         const url = r.path.endsWith('.md') ? window.prefix(`/note/${r.path}`) : window.prefix(`/view_text/${r.path}`);
 | |
|                         // remember query
 | |
|                         if (searchInput) localStorage.setItem(LS_KEY_QUERY, searchInput.value.trim());
 | |
|                         window.location.href = url;
 | |
|                     });
 | |
|                     item.appendChild(title);
 | |
| 
 | |
|                     (r.snippets || []).forEach(snip => {
 | |
|                         const pre = document.createElement('pre');
 | |
|                         pre.className = 'mt-2 bg-slate-800 p-2 rounded text-xs whitespace-pre-wrap border border-slate-600';
 | |
|                         pre.textContent = `Line ${snip.line}:\n` + snip.preview;
 | |
|                         item.appendChild(pre);
 | |
|                     });
 | |
|                     searchResults.appendChild(item);
 | |
|                 });
 | |
|             }
 | |
| 
 | |
|             const doSearch = debounce(async function(q) {
 | |
|                 const query = (q || '').trim();
 | |
|                 if (!query) {
 | |
|                     renderResults({ query: '', results: [] });
 | |
|                     return;
 | |
|                 }
 | |
|                 try {
 | |
|                     searchStatus.textContent = 'Searching...';
 | |
|                     const res = await fetch(window.prefix(`/api/search?q=${encodeURIComponent(query)}`));
 | |
|                     const data = await res.json();
 | |
|                     if (res.ok) {
 | |
|                         localStorage.setItem(LS_KEY_QUERY, query);
 | |
|                         renderResults(data);
 | |
|                     } else {
 | |
|                         searchStatus.textContent = data.error || 'Search failed';
 | |
|                     }
 | |
|                 } catch (e) {
 | |
|                     searchStatus.textContent = 'Search failed';
 | |
|                 }
 | |
|             }, 250);
 | |
| 
 | |
|             if (openSearchBtn) openSearchBtn.addEventListener('click', openSearch);
 | |
|             if (closeSearchBtn) closeSearchBtn.addEventListener('click', closeSearch);
 | |
|             if (clearSearchBtn) clearSearchBtn.addEventListener('click', clearSearch);
 | |
|             if (searchInput) searchInput.addEventListener('input', (e) => doSearch(e.target.value));
 | |
| 
 | |
|             // Close modal on overlay click (but not when clicking inside content)
 | |
|             if (searchModal) {
 | |
|                 searchModal.addEventListener('click', (e) => {
 | |
|                     if (e.target === searchModal) closeSearch();
 | |
|                 });
 | |
|                 document.addEventListener('keydown', (e) => {
 | |
|                     if (e.key === 'Escape' && !searchModal.classList.contains('hidden')) closeSearch();
 | |
|                 });
 | |
|             }
 | |
| 
 | |
|             // Logout handler (CSRF protected)
 | |
|             const logoutBtn = document.getElementById('logout-btn');
 | |
|             if (logoutBtn) {
 | |
|                 logoutBtn.addEventListener('click', async () => {
 | |
|                     try {
 | |
|                         const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
 | |
|                         const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
 | |
|                         const res = await fetch(window.prefix('/editor/logout'), {
 | |
|                             method: 'POST',
 | |
|                             headers: csrf ? { 'X-CSRF-Token': csrf } : {},
 | |
|                         });
 | |
|                         if (res.ok) {
 | |
|                             window.location.href = window.prefix('/editor/login');
 | |
|                         } else {
 | |
|                             const data = await res.json().catch(() => ({}));
 | |
|                             showNotification('Logout failed: ' + (data.error || res.statusText), 'error');
 | |
|                         }
 | |
|                     } catch (e) {
 | |
|                         showNotification('Logout error: ' + e.message, 'error');
 | |
|                     }
 | |
|                 });
 | |
|             }
 | |
|         });
 | |
|     </script>
 | |
|     
 | |
|     {{if eq .Page "folder"}}
 | |
|         {{template "folder_scripts" .}}
 | |
|     {{else if eq .Page "note"}}
 | |
|         {{template "note_scripts" .}}
 | |
|     {{else if eq .Page "view_text"}}
 | |
|         {{template "view_text_scripts" .}}
 | |
|     {{else if eq .Page "edit_text"}}
 | |
|         {{template "edit_text_scripts" .}}
 | |
|     {{else if eq .Page "create"}}
 | |
|         {{template "create_scripts" .}}
 | |
|     {{else if eq .Page "edit"}}
 | |
|         {{template "edit_scripts" .}}
 | |
|     {{else if eq .Page "settings"}}
 | |
|         {{template "settings_scripts" .}}
 | |
|     {{else if eq .Page "admin"}}
 | |
|         {{template "admin_scripts" .}}
 | |
|     {{else if eq .Page "admin_logs"}}
 | |
|         {{template "admin_logs_scripts" .}}
 | |
|     {{else if eq .Page "profile"}}
 | |
|         {{template "profile_scripts" .}}
 | |
|     {{else if eq .Page "error"}}
 | |
|         {{template "error_scripts" .}}
 | |
|     {{else if eq .Page "login"}}
 | |
|         {{template "login_scripts" .}}
 | |
|     {{else if eq .Page "mfa"}}
 | |
|         {{template "mfa_scripts" .}}
 | |
|     {{else if eq .Page "mfa_setup"}}
 | |
|         {{template "mfa_setup_scripts" .}}
 | |
|     {{end}}
 | |
| </body>
 | |
| </html>
 | |
| {{end}}
 | |
| <!-- Tree Node Template -->
 | |
| {{define "tree_node"}}
 | |
| <div class="tree-node">
 | |
|     {{if .node.Children}}
 | |
|         <div class="tree-toggle flex items-center py-1 hover:bg-gray-700 rounded px-2 cursor-pointer" data-path="{{.node.Path}}">
 | |
|             <i class="fas fa-chevron-right transform transition-transform duration-200 mr-2 text-xs tree-chevron"></i>
 | |
|             <span class="mr-2">📁</span>
 | |
|             <span class="flex-1">{{.node.Name}}</span>
 | |
|         </div>
 | |
|         <div class="tree-children ml-4 hidden">
 | |
|             {{range .node.Children}}
 | |
|                 {{template "tree_node" dict "node" . "active_path" $.active_path "current_note" $.current_note}}
 | |
|             {{end}}
 | |
|         </div>
 | |
|     {{else}}
 | |
|         {{if eq .node.Type "md"}}
 | |
|             <a href="{{url (print "/note/" .node.Path)}}" class="sidebar-item {{if eq .current_note .node.Path}}active{{end}}">
 | |
|                 <span class="mr-2">📝</span>
 | |
|                 <span>{{.node.Name}}</span>
 | |
|             </a>
 | |
|         {{else if eq .node.Type "image"}}
 | |
|             <a href="{{url (print "/serve_attached_image/" .node.Path)}}" target="_blank" class="sidebar-item" title="View image in new tab">
 | |
|                 <span class="mr-2">🖼️</span>
 | |
|                 <span>{{.node.Name}}</span>
 | |
|             </a>
 | |
|         {{else}}
 | |
|             <a href="{{url (print "/view_text/" .node.Path)}}" class="sidebar-item">
 | |
|                 <span class="mr-2">📄</span>
 | |
|                 <span>{{.node.Name}}</span>
 | |
|             </a>
 | |
|         {{end}}
 | |
|     {{end}}
 | |
| </div>
 | |
| {{end}} | 
