This commit is contained in:
pika 2025-04-14 22:25:26 +02:00
commit 345a801c40
33 changed files with 5499 additions and 0 deletions

View file

@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}Login - Vim Docs{% endblock %}
{% block auth_content %}
<div class="flex items-center justify-center min-h-screen bg-gray-900">
<div class="w-full max-w-md p-8 space-y-8 bg-gray-800 rounded-lg shadow-lg">
<div class="text-center">
<h1 class="text-3xl font-bold text-white">
<i class="mdi mdi-vim text-primary text-4xl mr-2"></i> Vim Docs
</h1>
<p class="mt-2 text-gray-400">Sign in to your account</p>
</div>
<form class="mt-8 space-y-6" method="POST" action="{{ url_for('auth.login') }}">
{{ form.hidden_tag() }}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="p-4 {{ 'bg-red-600/20 text-red-400' if category == 'error' else 'bg-green-600/20 text-green-400' }} rounded-md">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div>
<label for="username" class="block text-sm font-medium text-gray-400">Username</label>
<div class="mt-1">
{{ form.username(class="block w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary") }}
</div>
{% for error in form.username.errors %}
<p class="mt-1 text-sm text-red-400">{{ error }}</p>
{% endfor %}
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-400">Password</label>
<div class="mt-1">
{{ form.password(class="block w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary") }}
</div>
{% for error in form.password.errors %}
<p class="mt-1 text-sm text-red-400">{{ error }}</p>
{% endfor %}
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
{{ form.remember_me(class="h-4 w-4 text-primary focus:ring-primary border-gray-600 rounded bg-gray-700") }}
<label for="remember_me" class="ml-2 block text-sm text-gray-400">
Remember me
</label>
</div>
</div>
<div>
{{ form.submit(class="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-black bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary") }}
</div>
<div class="text-center mt-4">
<p class="text-sm text-gray-400">
Don't have an account?
<a href="{{ url_for('auth.signup') }}" class="font-medium text-primary hover:text-primary-light">
Sign up
</a>
</p>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,89 @@
{% extends "base.html" %}
{% block title %}Settings - Vim Docs{% endblock %}
{% block header_title %}Settings{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto p-6">
<div class="bg-gray-800 shadow-md rounded-lg overflow-hidden">
<div class="border-b border-gray-700 p-6">
<h2 class="text-xl font-semibold text-white mb-2">User Settings</h2>
<p class="text-gray-400">Customize your Vim Docs experience</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="p-4 m-4 {{ 'bg-red-600/20 text-red-400' if category == 'error' else 'bg-green-600/20 text-green-400' }} rounded-md">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.settings') }}" class="p-6 space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<h3 class="text-lg font-medium text-white mb-4">Theme Settings</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">
Primary Color
</label>
<div class="flex items-center">
<input type="color" id="theme_color" name="theme_color"
value="{{ current_user.theme_color }}"
class="h-10 w-20 bg-transparent rounded border border-gray-600">
<span class="ml-3 text-gray-400 text-sm">{{ current_user.theme_color }}</span>
</div>
</div>
<div class="mt-4">
<p class="text-sm text-gray-400 mb-2">Color Presets</p>
<div class="flex gap-2">
<button type="button" data-color="#50fa7b" class="preset-color w-8 h-8 rounded-full bg-[#50fa7b]"></button>
<button type="button" data-color="#bd93f9" class="preset-color w-8 h-8 rounded-full bg-[#bd93f9]"></button>
<button type="button" data-color="#ff79c6" class="preset-color w-8 h-8 rounded-full bg-[#ff79c6]"></button>
<button type="button" data-color="#f1fa8c" class="preset-color w-8 h-8 rounded-full bg-[#f1fa8c]"></button>
<button type="button" data-color="#8be9fd" class="preset-color w-8 h-8 rounded-full bg-[#8be9fd]"></button>
<button type="button" data-color="#ffb86c" class="preset-color w-8 h-8 rounded-full bg-[#ffb86c]"></button>
</div>
</div>
</div>
</div>
<div class="mt-6 pt-6 border-t border-gray-700">
<button type="submit" class="px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
Save Settings
</button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const colorInput = document.getElementById('theme_color');
const presetButtons = document.querySelectorAll('.preset-color');
presetButtons.forEach(button => {
button.addEventListener('click', function() {
const color = this.getAttribute('data-color');
colorInput.value = color;
// Apply selected style to the button
presetButtons.forEach(btn => btn.classList.remove('ring-2', 'ring-white'));
this.classList.add('ring-2', 'ring-white');
});
// Apply selected style to the current color on load
if (button.getAttribute('data-color') === colorInput.value) {
button.classList.add('ring-2', 'ring-white');
}
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}Sign Up - Vim Docs{% endblock %}
{% block auth_content %}
<div class="flex items-center justify-center min-h-screen bg-gray-900">
<div class="w-full max-w-md p-8 space-y-8 bg-gray-800 rounded-lg shadow-lg">
<div class="text-center">
<h1 class="text-3xl font-bold text-white">
<i class="mdi mdi-vim text-primary text-4xl mr-2"></i> Vim Docs
</h1>
<p class="mt-2 text-gray-400">Create your account</p>
</div>
<form class="mt-8 space-y-6" method="POST" action="{{ url_for('auth.signup') }}">
{{ form.hidden_tag() }}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="p-4 {{ 'bg-red-600/20 text-red-400' if category == 'error' else 'bg-green-600/20 text-green-400' }} rounded-md">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div>
<label for="username" class="block text-sm font-medium text-gray-400">Username</label>
<div class="mt-1">
{{ form.username(class="block w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary") }}
</div>
{% for error in form.username.errors %}
<p class="mt-1 text-sm text-red-400">{{ error }}</p>
{% endfor %}
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-400">Password</label>
<div class="mt-1">
{{ form.password(class="block w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary", placeholder="Minimum 8 characters") }}
</div>
{% for error in form.password.errors %}
<p class="mt-1 text-sm text-red-400">{{ error }}</p>
{% endfor %}
</div>
<div>
<label for="password2" class="block text-sm font-medium text-gray-400">Confirm Password</label>
<div class="mt-1">
{{ form.password2(class="block w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary") }}
</div>
{% for error in form.password2.errors %}
<p class="mt-1 text-sm text-red-400">{{ error }}</p>
{% endfor %}
</div>
<div>
{{ form.submit(class="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-black bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary") }}
</div>
<div class="text-center mt-4">
<p class="text-sm text-gray-400">
Already have an account?
<a href="{{ url_for('auth.login') }}" class="font-medium text-primary hover:text-primary-light">
Sign in
</a>
</p>
</div>
</form>
</div>
</div>
{% endblock %}

332
app/templates/base.html Normal file
View file

@ -0,0 +1,332 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Vim Docs{% endblock %}</title>
<!-- Material Design Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@6.9.96/css/materialdesignicons.min.css">
<!-- CascadyaCove Nerd Font -->
<style>
@font-face {
font-family: 'CascadyaCove NF';
src: url('/static/fonts/CascadiaCove.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
</style>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {% if current_user.is_authenticated %}'{{ current_user.theme_color }}'{% else %}'#50fa7b'{% endif %},
'primary-dark': {% if current_user.is_authenticated %}'{{ current_user.theme_color }}'{% else %}'#39bd5e'{% endif %},
'primary-light': {% if current_user.is_authenticated %}'{{ current_user.theme_color }}'{% else %}'#7dfb96'{% endif %},
'gray': {
800: '#282a36',
900: '#1e1f29',
}
},
fontFamily: {
mono: ['CascadyaCove NF', 'monospace']
}
}
}
};
</script>
{% block extra_css %}{% endblock %}
</head>
<body class="bg-gray-900 text-gray-300 min-h-screen font-sans">
{% if current_user.is_authenticated %}
<div class="flex min-h-screen {% if request.cookies.get('sidebar_collapsed') == 'true' %}sidebar-hidden{% endif %}">
<!-- Sidebar -->
<aside class="w-64 bg-gray-800 h-screen flex-shrink-0 fixed left-0 top-0 z-10 overflow-y-auto transition-all ease-in-out duration-300">
<div class="p-4 border-b border-gray-700 flex items-center">
<h1 class="text-xl font-semibold text-white flex items-center">
<i class="mdi mdi-vim text-primary text-2xl mr-2"></i> Vim Docs
</h1>
</div>
<nav class="py-4">
<ul class="list-none">
<li class="my-1">
<a href="{{ url_for('main.index') }}" class="flex items-center px-4 py-2 text-gray-300 hover:text-primary hover:bg-gray-700 rounded-md transition-all {{ 'bg-primary/10 text-primary' if request.endpoint == 'main.index' }}">
<i class="mdi mdi-home mr-3"></i> Home
</a>
</li>
<li class="my-4">
<div class="block font-medium text-gray-400 px-4 py-2 flex items-center">
<i class="mdi mdi-folder-multiple-outline mr-3"></i> Categories
</div>
<ul class="ml-3 pl-3 border-l border-gray-700 my-1" id="category-tree">
<!-- Categories will be loaded here via JS -->
</ul>
</li>
<li class="my-4">
<div class="block font-medium text-gray-400 px-4 py-2 flex items-center">
<i class="mdi mdi-file-document-multiple-outline mr-3"></i> Documents
</div>
<ul class="ml-3 pl-3 border-l border-gray-700 my-1">
<li>
<a href="{{ url_for('main.new_document') }}" class="flex items-center py-1 px-2 text-gray-400 hover:text-primary rounded transition-colors {{ 'text-primary' if request.endpoint == 'main.new_document' }}">
<i class="mdi mdi-plus-circle-outline mr-2 text-sm"></i> New Document
</a>
</li>
<li>
<a href="{{ url_for('main.index') }}?view=recent" class="flex items-center py-1 px-2 text-gray-400 hover:text-primary rounded transition-colors">
<i class="mdi mdi-clock-outline mr-2 text-sm"></i> Recent Documents
</a>
</li>
</ul>
</li>
<li class="my-1">
<a href="{{ url_for('main.new_document') }}" class="flex items-center px-4 py-2 bg-primary text-black hover:bg-primary-dark rounded-md transition-all">
<i class="mdi mdi-plus-circle mr-3"></i> New Document
</a>
</li>
</ul>
</nav>
</aside>
<!-- Main content -->
<main class="flex-1 flex flex-col ml-64 transition-all ease-in-out duration-300">
<!-- Header -->
<header class="bg-gray-800 border-b border-gray-700 p-4 flex justify-between items-center sticky top-0 z-10">
<div class="flex items-center">
<button id="toggle-sidebar" class="p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors mr-3">
<i class="mdi mdi-menu"></i>
</button>
<h2 class="text-white text-lg font-medium">{% block header_title %}Dashboard{% endblock %}</h2>
<div class="relative ml-4">
<div class="flex items-center h-9 rounded-md border border-gray-700 bg-gray-900">
<i class="mdi mdi-magnify text-gray-400 mx-3"></i>
<input type="text" id="search-input"
class="bg-transparent border-0 outline-none text-white w-64"
placeholder="Search documents...">
</div>
<div id="search-results" class="absolute left-0 right-0 top-full mt-1 bg-gray-800 rounded-md shadow-lg z-20 max-h-96 overflow-y-auto hidden"></div>
</div>
</div>
<div class="flex items-center space-x-2">
{% block header_actions %}{% endblock %}
<div class="relative ml-2">
<button id="user-menu-button" class="p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors flex items-center">
<span class="w-8 h-8 bg-primary/20 rounded-full flex items-center justify-center text-primary mr-2">
{{ current_user.username[0].upper() }}
</span>
<span class="hidden md:inline mr-1">{{ current_user.username }}</span>
<i class="mdi mdi-chevron-down text-sm"></i>
</button>
<div id="user-dropdown" class="absolute right-0 mt-2 py-2 w-48 bg-gray-800 rounded-md shadow-lg z-20 hidden">
<a href="{{ url_for('auth.settings') }}" class="{{ 'bg-gray-700' if request.endpoint == 'auth.settings' }} block px-4 py-2 text-gray-300 hover:bg-gray-700 hover:text-white w-full text-left">
<i class="mdi mdi-cog-outline mr-2"></i> Settings
</a>
<a href="{{ url_for('auth.logout') }}" class="block px-4 py-2 text-gray-300 hover:bg-gray-700 hover:text-white w-full text-left">
<i class="mdi mdi-logout-variant mr-2"></i> Sign Out
</a>
</div>
</div>
</div>
</header>
<!-- Content area -->
<div class="flex-1 p-6 overflow-auto">
{% block content %}{% endblock %}
</div>
</main>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Toggle sidebar
const toggleBtn = document.getElementById('toggle-sidebar');
if (toggleBtn) {
toggleBtn.addEventListener('click', function() {
document.querySelector('body > div').classList.toggle('sidebar-hidden');
// Save preference in cookie
const isCollapsed = document.querySelector('body > div').classList.contains('sidebar-hidden');
document.cookie = `sidebar_collapsed=${isCollapsed}; path=/; max-age=31536000`;
});
}
// User dropdown
const userMenuBtn = document.getElementById('user-menu-button');
const userDropdown = document.getElementById('user-dropdown');
if (userMenuBtn && userDropdown) {
userMenuBtn.addEventListener('click', function(e) {
e.stopPropagation();
userDropdown.classList.toggle('hidden');
});
document.addEventListener('click', function(e) {
if (!userMenuBtn.contains(e.target) && !userDropdown.contains(e.target)) {
userDropdown.classList.add('hidden');
}
});
}
// Load categories
loadCategories();
// Search functionality
setupSearch();
});
function loadCategories() {
const categoryTree = document.getElementById('category-tree');
if (!categoryTree) return;
fetch('/api/categories')
.then(response => response.json())
.then(categories => {
categoryTree.innerHTML = ''; // Clear existing items
if (categories.length === 0) {
categoryTree.innerHTML = '<li class="px-4 py-2 text-gray-500">No categories found</li>';
return;
}
categories.forEach(category => {
categoryTree.appendChild(createCategoryItem(category));
});
})
.catch(error => {
console.error('Error loading categories:', error);
categoryTree.innerHTML = '<li class="px-4 py-2 text-red-500">Error loading categories</li>';
});
}
function createCategoryItem(category) {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = `/category/${category.id}`;
a.className = 'flex items-center py-1 px-2 text-gray-400 hover:text-primary rounded transition-colors';
a.innerHTML = `<i class="mdi ${category.icon} mr-2 text-sm"></i> ${category.name}`;
li.appendChild(a);
if (category.children && category.children.length > 0) {
const childrenUl = document.createElement('ul');
childrenUl.className = 'ml-2 pl-2 border-l border-gray-700 my-1';
category.children.forEach(child => {
childrenUl.appendChild(createCategoryItem(child));
});
li.appendChild(childrenUl);
}
return li;
}
function setupSearch() {
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
if (!searchInput || !searchResults) return;
// Debounce function
function debounce(func, wait) {
let timeout;
return function() {
const context = this;
const args = arguments;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
};
}
const performSearch = debounce(function() {
const query = searchInput.value.trim();
if (query.length < 2) {
searchResults.classList.add('hidden');
return;
}
fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
searchResults.innerHTML = '';
if (data.results.length === 0) {
searchResults.innerHTML = '<div class="p-4 text-gray-400">No results found</div>';
} else {
data.results.forEach(result => {
const item = document.createElement('a');
item.href = `/document/${result.id}`;
item.className = 'block p-3 hover:bg-gray-700 border-b border-gray-700 last:border-0';
let tagsHtml = '';
if (result.tags && result.tags.length > 0) {
tagsHtml = '<div class="flex gap-1 mt-1">' +
result.tags.map(tag => `<span class="text-xs px-2 py-1 bg-primary/20 text-primary rounded">${tag}</span>`).join('') +
'</div>';
}
let matchHtml = '';
if (result.match) {
const regex = new RegExp(`(${query})`, 'gi');
matchHtml = `<div class="text-sm text-gray-400 mt-1">${result.match.replace(regex, '<span class="bg-primary/30 text-primary">$1</span>')}</div>`;
}
item.innerHTML = `
<div class="font-medium text-white">${result.title}</div>
<div class="text-xs text-gray-400 mt-1">${result.category || 'Uncategorized'}</div>
${tagsHtml}
${matchHtml}
`;
searchResults.appendChild(item);
});
}
searchResults.classList.remove('hidden');
})
.catch(error => {
console.error('Search error:', error);
searchResults.innerHTML = '<div class="p-4 text-red-400">Error performing search</div>';
searchResults.classList.remove('hidden');
});
}, 300);
searchInput.addEventListener('input', performSearch);
searchInput.addEventListener('focus', function() {
if (searchInput.value.trim().length >= 2) {
searchResults.classList.remove('hidden');
}
});
document.addEventListener('click', function(e) {
if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) {
searchResults.classList.add('hidden');
}
});
}
</script>
{% else %}
<div class="bg-gray-900 min-h-screen">
{% block auth_content %}{% endblock %}
</div>
{% endif %}
{% block extra_js %}{% endblock %}
</body>
</html>

364
app/templates/category.html Normal file
View file

@ -0,0 +1,364 @@
{% extends "base.html" %}
{% block title %}{{ category.name }} - Vim Docs{% endblock %}
{% block header_title %}
<i class="mdi {{ category.icon }} mr-2"></i> {{ category.name }}
{% endblock %}
{% block header_actions %}
<a href="{{ url_for('main.new_document') }}?category={{ category.id }}" class="inline-flex items-center px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
<i class="mdi mdi-plus mr-2"></i> New Document
</a>
<button id="edit-category-btn" class="inline-flex items-center px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-600 transition-colors ml-2">
<i class="mdi mdi-pencil mr-2"></i> Edit Category
</button>
{% endblock %}
{% block content %}
<div class="space-y-8">
<!-- Current category information -->
<div class="bg-gray-800 rounded-lg overflow-hidden shadow-lg p-6">
<div class="flex items-center mb-2">
<div class="w-10 h-10 rounded-md bg-primary/20 flex items-center justify-center text-primary mr-3">
<i class="mdi {{ category.icon }} text-2xl"></i>
</div>
<h1 class="text-2xl font-bold text-white">{{ category.name }}</h1>
</div>
<div class="text-gray-400 mb-4">
{% if category.description %}
{{ category.description }}
{% else %}
A category for organizing your documents
{% endif %}
</div>
<div class="flex text-sm text-gray-500">
<div class="flex items-center">
<i class="mdi mdi-folder-outline mr-1"></i>
<a href="{{ url_for('main.index') }}" class="hover:text-primary">Home</a>
{% if category.parent %}
<span class="mx-2">/</span>
<a href="{{ url_for('main.view_category', category_id=category.parent.id) }}" class="hover:text-primary">{{ category.parent.name }}</a>
{% endif %}
<span class="mx-2">/</span>
<span class="text-gray-400">{{ category.name }}</span>
</div>
</div>
</div>
<!-- Subcategories -->
{% if category.children.count() > 0 %}
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-white">Subcategories</h2>
<button id="add-subcategory-btn" class="icon-button">
<i class="mdi mdi-plus"></i>
</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{% for subcategory in category.children %}
<div class="bg-gray-800 rounded-lg overflow-hidden shadow hover:shadow-lg transition-all hover:-translate-y-1 duration-200">
<a href="{{ url_for('main.view_category', category_id=subcategory.id) }}" class="block p-5">
<div class="flex items-center mb-3">
<div class="w-10 h-10 rounded-md bg-primary/20 flex items-center justify-center text-primary mr-3">
<i class="mdi {{ subcategory.icon }} text-2xl"></i>
</div>
<h3 class="text-white font-medium truncate">{{ subcategory.name }}</h3>
</div>
{% if subcategory.description %}
<p class="text-gray-400 text-sm mb-4 line-clamp-2">{{ subcategory.description }}</p>
{% endif %}
<div class="flex items-center justify-between text-xs text-gray-500">
<span>{{ subcategory.documents.count() }} document{{ '' if subcategory.documents.count() == 1 else 's' }}</span>
<span>{{ subcategory.children.count() }} subcategor{{ 'y' if subcategory.children.count() == 1 else 'ies' }}</span>
</div>
</a>
</div>
{% endfor %}
<!-- Add subcategory card -->
<div id="add-subcategory-card" class="bg-gray-800/50 border-2 border-dashed border-gray-700 rounded-lg overflow-hidden hover:border-primary/50 hover:bg-gray-800/80 transition-all cursor-pointer">
<div class="p-5 h-full flex flex-col items-center justify-center text-center">
<div class="w-12 h-12 rounded-full bg-gray-700/50 flex items-center justify-center mb-3">
<i class="mdi mdi-folder-plus-outline text-2xl text-gray-500"></i>
</div>
<h3 class="text-gray-400 font-medium mb-1">New Subcategory</h3>
<p class="text-gray-500 text-sm">Add a subcategory to {{ category.name }}</p>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Documents -->
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-white">Documents</h2>
<a href="{{ url_for('main.new_document') }}?category={{ category.id }}" class="icon-button" title="New Document">
<i class="mdi mdi-plus"></i>
</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% if category.documents.count() > 0 %}
{% for doc in category.documents %}
<div class="bg-gray-800 rounded-lg overflow-hidden shadow hover:shadow-lg transition-shadow">
<div class="p-5">
<div class="flex items-start justify-between">
<h3 class="text-white font-medium mb-2 truncate">
<a href="{{ url_for('main.view_document', doc_id=doc.id) }}" class="hover:text-primary transition-colors">
{{ doc.title }}
</a>
</h3>
<div class="dropdown relative ml-2">
<button class="icon-button p-1">
<i class="mdi mdi-dots-vertical"></i>
</button>
<div class="dropdown-menu hidden absolute right-0 mt-2 w-40 bg-gray-700 rounded-md shadow-lg z-10">
<a href="{{ url_for('main.edit_document', doc_id=doc.id) }}" class="block px-4 py-2 text-gray-300 hover:bg-gray-600 hover:text-white">
<i class="mdi mdi-pencil mr-2"></i> Edit
</a>
<a href="{{ url_for('main.export_document', doc_id=doc.id) }}" class="block px-4 py-2 text-gray-300 hover:bg-gray-600 hover:text-white">
<i class="mdi mdi-download mr-2"></i> Export
</a>
</div>
</div>
</div>
<div class="text-gray-400 text-sm mb-3 truncate">
{{ (doc.content[:100] + '...') if doc.content|length > 100 else doc.content }}
</div>
<div class="flex items-center justify-between mt-4">
<div class="text-xs text-gray-500">
<i class="mdi mdi-calendar-outline mr-1"></i> {{ doc.updated_date.strftime('%b %d, %Y') }}
</div>
</div>
{% if doc.tags %}
<div class="flex flex-wrap gap-1 mt-3">
{% for tag in doc.tags %}
<span class="text-xs px-2 py-1 bg-primary/20 text-primary rounded-full">{{ tag.name }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="col-span-full bg-gray-800/50 rounded-lg p-8 text-center">
<i class="mdi mdi-file-document-outline text-6xl text-gray-700 mb-3"></i>
<h3 class="text-lg text-gray-400 mb-3">No documents in this category</h3>
<p class="text-gray-500 mb-4">Create your first document in this category</p>
<a href="{{ url_for('main.new_document') }}?category={{ category.id }}" class="inline-flex items-center px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
<i class="mdi mdi-plus mr-2"></i> Create Document
</a>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Category Modal -->
<div id="category-modal" class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center hidden">
<div class="bg-gray-800 rounded-lg shadow-lg w-full max-w-md mx-4">
<div class="flex items-center justify-between p-4 border-b border-gray-700">
<h3 id="modal-title" class="text-lg font-medium text-white">Add Subcategory</h3>
<button class="close-modal text-gray-400 hover:text-white">
<i class="mdi mdi-close text-lg"></i>
</button>
</div>
<div class="p-6">
<form id="category-form" class="space-y-4">
<input type="hidden" id="category-id" value="">
<div>
<label for="category-name" class="block text-sm font-medium text-gray-400 mb-1">Name</label>
<input type="text" id="category-name" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
</div>
<div>
<label for="category-icon" class="block text-sm font-medium text-gray-400 mb-1">Icon</label>
<div class="flex items-center">
<input type="text" id="category-icon" value="mdi-folder-outline" class="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
<div class="ml-3 w-10 h-10 flex items-center justify-center text-primary bg-primary/10 rounded-md">
<i id="icon-preview" class="mdi mdi-folder-outline text-xl"></i>
</div>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<button type="button" data-icon="mdi-folder-outline" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
<i class="mdi mdi-folder-outline"></i>
</button>
<button type="button" data-icon="mdi-folder-text-outline" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
<i class="mdi mdi-folder-text-outline"></i>
</button>
<button type="button" data-icon="mdi-code-braces" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
<i class="mdi mdi-code-braces"></i>
</button>
<button type="button" data-icon="mdi-database" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
<i class="mdi mdi-database"></i>
</button>
<button type="button" data-icon="mdi-web" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
<i class="mdi mdi-web"></i>
</button>
<button type="button" data-icon="mdi-book-outline" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
<i class="mdi mdi-book-outline"></i>
</button>
</div>
</div>
<div>
<label for="category-description" class="block text-sm font-medium text-gray-400 mb-1">Description (Optional)</label>
<input type="text" id="category-description" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
</div>
<div>
<label for="category-parent" class="block text-sm font-medium text-gray-400 mb-1">Parent Category</label>
<select id="category-parent" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
<option value="">None</option>
<option value="{{ category.id }}" selected>{{ category.name }}</option>
</select>
</div>
<div class="pt-4 flex justify-end space-x-3">
<button type="button" id="cancel-btn" class="px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-600 transition-colors">
Cancel
</button>
<button type="submit" class="px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
Save
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const modal = document.getElementById('category-modal');
const addBtn = document.getElementById('add-subcategory-btn');
const addCard = document.getElementById('add-subcategory-card');
const editBtn = document.getElementById('edit-category-btn');
const closeBtn = document.querySelector('.close-modal');
const cancelBtn = document.getElementById('cancel-btn');
const form = document.getElementById('category-form');
const modalTitle = document.getElementById('modal-title');
const iconInput = document.getElementById('category-icon');
const iconPreview = document.getElementById('icon-preview');
const iconSelectBtns = document.querySelectorAll('.icon-select');
// Open modal
function openModal(isEdit = false) {
if (isEdit) {
// Edit the current category
modalTitle.textContent = 'Edit Category';
document.getElementById('category-id').value = '{{ category.id }}';
document.getElementById('category-name').value = '{{ category.name }}';
document.getElementById('category-icon').value = '{{ category.icon }}';
document.getElementById('category-parent').value = '{{ category.parent_id }}' || '';
iconPreview.className = 'mdi {{ category.icon }} text-xl';
} else {
// Add new subcategory
modalTitle.textContent = 'Add Subcategory';
document.getElementById('category-id').value = '';
document.getElementById('category-name').value = '';
document.getElementById('category-icon').value = 'mdi-folder-outline';
document.getElementById('category-parent').value = '{{ category.id }}';
iconPreview.className = 'mdi mdi-folder-outline text-xl';
}
modal.classList.remove('hidden');
document.getElementById('category-name').focus();
}
// Close modal
function closeModal() {
modal.classList.add('hidden');
}
// Update icon preview
if (iconInput) {
iconInput.addEventListener('input', function() {
iconPreview.className = 'mdi ' + this.value + ' text-xl';
});
}
// Icon selection
iconSelectBtns.forEach(btn => {
btn.addEventListener('click', function() {
const icon = this.getAttribute('data-icon');
iconInput.value = icon;
iconPreview.className = 'mdi ' + icon + ' text-xl';
// Highlight selected icon
iconSelectBtns.forEach(b => b.classList.remove('ring-2', 'ring-primary'));
this.classList.add('ring-2', 'ring-primary');
});
});
// Event listeners
if (addBtn) addBtn.addEventListener('click', () => openModal(false));
if (addCard) addCard.addEventListener('click', () => openModal(false));
if (editBtn) editBtn.addEventListener('click', () => openModal(true));
if (closeBtn) closeBtn.addEventListener('click', closeModal);
if (cancelBtn) cancelBtn.addEventListener('click', closeModal);
// Form submission
if (form) {
form.addEventListener('submit', function(e) {
e.preventDefault();
const formData = {
id: document.getElementById('category-id').value || null,
name: document.getElementById('category-name').value,
icon: document.getElementById('category-icon').value,
description: document.getElementById('category-description').value,
parent_id: document.getElementById('category-parent').value || null
};
fetch('/api/category', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
})
.then(response => response.json())
.then(data => {
window.location.reload();
})
.catch(error => console.error('Error:', error));
});
}
// Dropdown functionality for document cards
document.querySelectorAll('.dropdown button').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const menu = this.nextElementSibling;
menu.classList.toggle('hidden');
// Close other open dropdowns
document.querySelectorAll('.dropdown-menu:not(.hidden)').forEach(m => {
if (m !== menu) m.classList.add('hidden');
});
});
});
// Close dropdowns when clicking outside
document.addEventListener('click', function() {
document.querySelectorAll('.dropdown-menu:not(.hidden)').forEach(menu => {
menu.classList.add('hidden');
});
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,491 @@
{% extends "base.html" %}
{% block title %}{% if document %}Edit: {{ document.title }}{% else %}New Document{% endif %} - Vim Docs{% endblock %}
{% block header_title %}{% if document %}Edit: {{ document.title }}{% else %}New Document{% endif %}{% endblock %}
{% block header_actions %}
<button id="save-document" class="inline-flex items-center px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
<i class="mdi mdi-content-save mr-2"></i> Save
</button>
{% if document and document.id %}
<a href="{{ url_for('main.export_document', doc_id=document.id) }}" class="inline-flex items-center px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-600 transition-colors ml-2">
<i class="mdi mdi-download mr-2"></i> Export
</a>
{% endif %}
<button id="toggle-preview" class="inline-flex items-center px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-600 transition-colors ml-2">
<i class="mdi mdi-eye mr-2"></i> Toggle Preview
</button>
{% endblock %}
{% block extra_css %}
<!-- CodeMirror CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/dracula.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/dialog/dialog.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/hint/show-hint.min.css">
<!-- GitHub Markdown CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.2.0/github-markdown-dark.min.css">
<style>
/* Editor-specific styles */
.editor-container {
display: flex;
height: calc(100vh - 64px);
overflow: hidden;
}
.editor-pane, .preview-pane {
flex: 1;
height: 100%;
overflow: auto;
transition: all 0.3s ease;
}
.editor-pane {
position: relative;
font-family: 'CascadyaCove NF', monospace;
}
.preview-pane {
padding: 0;
border-left: 1px solid #374151;
background-color: #0d1117; /* GitHub dark theme background */
}
.CodeMirror {
font-family: 'CascadyaCove NF', monospace !important;
height: 100% !important;
font-size: 15px;
}
.split-horizontal .editor-container {
flex-direction: row;
}
.split-vertical .editor-container {
flex-direction: column;
}
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
color: #c9d1d9; /* GitHub dark theme text color */
}
/* GitHub-style admonitions/alerts */
.markdown-body .admonition {
padding: 1rem;
border-left: 4px solid;
margin: 1em 0;
border-radius: 6px;
background-color: rgba(175, 184, 193, 0.2);
}
.markdown-body .admonition-title {
font-weight: 600;
margin-top: 0;
}
/* Regular blockquotes */
.markdown-body blockquote {
padding: 0.5rem 1rem;
color: #8b949e;
border-left: 0.25em solid #30363d;
margin: 1em 0;
background-color: rgba(55, 65, 81, 0.1);
}
.markdown-body blockquote > :first-child {
margin-top: 0;
}
.markdown-body blockquote > :last-child {
margin-bottom: 0;
}
.markdown-body .admonition-note {
border-color: #2b6eff;
background-color: rgba(43, 110, 255, 0.1);
}
.markdown-body .admonition-note .admonition-title {
color: #2b6eff;
}
.markdown-body .admonition-tip {
border-color: #3fb950;
background-color: rgba(63, 185, 80, 0.1);
}
.markdown-body .admonition-tip .admonition-title {
color: #3fb950;
}
.markdown-body .admonition-important {
border-color: #a371f7;
background-color: rgba(163, 113, 247, 0.1);
}
.markdown-body .admonition-important .admonition-title {
color: #a371f7;
}
.markdown-body .admonition-warning {
border-color: #d29922;
background-color: rgba(210, 153, 34, 0.1);
}
.markdown-body .admonition-warning .admonition-title {
color: #d29922;
}
.markdown-body .admonition-caution {
border-color: #f85149;
background-color: rgba(248, 81, 73, 0.1);
}
.markdown-body .admonition-caution .admonition-title {
color: #f85149;
}
.markdown-body .admonition-danger {
border-color: #cf222e;
background-color: rgba(207, 34, 46, 0.1);
}
.markdown-body .admonition-danger .admonition-title {
color: #cf222e;
}
</style>
{% endblock %}
{% block content %}
<!-- Document info bar -->
<div class="bg-gray-800 rounded-lg mb-4 shadow-lg overflow-hidden">
<div class="p-4 grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="doc-title" class="block text-sm font-medium text-gray-400 mb-1">Document Title</label>
<input type="text" id="doc-title"
value="{% if document %}{{ document.title }}{% else %}Untitled Document{% endif %}"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
</div>
<div>
<label for="doc-category" class="block text-sm font-medium text-gray-400 mb-1">Category</label>
<select id="doc-category" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
{% for category in categories %}
{% if category.is_root %}
<option value="{{ category.id }}" {% if (document and document.category_id == category.id) or (not document and (not preselected_category_id or preselected_category_id|int == category.id|int)) %}selected{% endif %}>
{{ category.name }} (Home)
</option>
{% else %}
<option value="{{ category.id }}" {% if (document and document.category_id == category.id) or (not document and preselected_category_id and preselected_category_id|int == category.id|int) %}selected{% endif %}>
{{ category.name }}
</option>
{% endif %}
{% endfor %}
</select>
</div>
<div>
<label for="doc-tags" class="block text-sm font-medium text-gray-400 mb-1">Tags</label>
<div class="relative">
<input type="text" id="doc-tags" placeholder="Add tags..."
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
<div class="tag-list flex flex-wrap gap-1 mt-2">
{% if document and document.tags %}
{% for tag in document.tags %}
<span class="tag px-2 py-1 bg-primary/20 text-primary rounded-full text-xs flex items-center">
{{ tag.name }}
<span class="remove-tag ml-1 cursor-pointer">&times;</span>
</span>
{% endfor %}
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Editor container -->
<div class="editor-container bg-gray-800 rounded-lg overflow-hidden shadow-lg">
<div class="editor-pane">
<textarea id="editor">{% if document %}{{ document.content }}{% endif %}</textarea>
</div>
<div class="preview-pane">
<div id="preview" class="markdown-body"></div>
</div>
</div>
<!-- Save notification -->
<div id="save-notification" class="fixed bottom-4 right-4 bg-green-500/90 text-white px-4 py-2 rounded-md shadow-lg transform translate-y-16 opacity-0 transition-all duration-300 flex items-center">
<i class="mdi mdi-check-circle mr-2"></i>
<span>Document saved successfully!</span>
</div>
{% endblock %}
{% block extra_js %}
<!-- CodeMirror JS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/markdown/markdown.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/edit/continuelist.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/edit/closebrackets.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/mode/overlay.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/selection/active-line.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/dialog/dialog.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/search/searchcursor.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/search/search.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/keymap/vim.min.js"></script>
<!-- Marked.js for Markdown -->
<script src="https://cdn.jsdelivr.net/npm/marked@4.0.16/marked.min.js"></script>
<script>
// Initialize CodeMirror
const editor = CodeMirror.fromTextArea(document.getElementById('editor'), {
mode: 'markdown',
theme: 'dracula',
lineNumbers: true,
lineWrapping: true,
indentWithTabs: false,
tabSize: 2,
keyMap: 'vim',
styleActiveLine: true,
autoCloseBrackets: true,
extraKeys: {
"Enter": "newlineAndIndentContinueMarkdownList"
}
});
// Setup marked.js for markdown rendering
marked.setOptions({
gfm: true, // GitHub flavored markdown
breaks: true, // Convert newlines to <br>
headerIds: true,
sanitize: false, // Allow raw HTML
smartLists: true,
smartypants: true, // Typographic fixes
highlight: function(code) {
return `<pre><code>${code}</code></pre>`;
}
});
// Custom renderer for handling GitHub-style alert blocks
const renderer = new marked.Renderer();
// Store the original blockquote renderer
const originalBlockquote = renderer.blockquote;
// Override blockquote to support admonitions
renderer.blockquote = function(quote) {
// Check for GitHub-style admonition format [!NOTE], [!TIP], etc.
const text = quote.replace(/<\/?p>/g, ''); // Extract text without p tags
const admonitionRegex = /^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION|DANGER)\]\s*([\s\S]*)/i;
const match = text.match(admonitionRegex);
if (match) {
const type = match[1].toLowerCase();
const title = match[1].charAt(0).toUpperCase() + match[1].slice(1).toLowerCase();
const content = match[2] ? match[2].trim() : '';
return `<div class="admonition admonition-${type}">
<p class="admonition-title">${title}</p>
<p>${content}</p>
</div>`;
}
// Fall back to the original blockquote renderer
return originalBlockquote.call(this, quote);
};
marked.use({ renderer });
// Update markdown preview
function updatePreview() {
const content = editor.getValue();
const preview = document.getElementById('preview');
preview.innerHTML = marked.parse(content);
}
// Initialize
document.addEventListener('DOMContentLoaded', function() {
// Initial preview
updatePreview();
// Add debounce function for efficiency
function debounce(func, wait) {
let timeout;
return function() {
const context = this;
const args = arguments;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
};
}
// Debounced preview update
const debouncedUpdate = debounce(updatePreview, 300);
// Update preview when content changes
editor.on('change', debouncedUpdate);
// Sync scroll between editor and preview
const previewPane = document.querySelector('.preview-pane');
// Editor to Preview scroll sync
editor.on('scroll', function() {
const scrollInfo = editor.getScrollInfo();
const ratio = scrollInfo.top / (scrollInfo.height - scrollInfo.clientHeight);
const previewHeight = previewPane.scrollHeight - previewPane.clientHeight;
// Add a flag to prevent infinite scroll loop
if (!previewPane.isScrolling) {
editor.isScrolling = true;
previewPane.scrollTop = ratio * previewHeight;
setTimeout(() => { editor.isScrolling = false; }, 50);
}
});
// Preview to Editor scroll sync
previewPane.addEventListener('scroll', function() {
if (!editor.isScrolling) {
const ratio = previewPane.scrollTop / (previewPane.scrollHeight - previewPane.clientHeight);
const scrollInfo = editor.getScrollInfo();
const editorHeight = scrollInfo.height - scrollInfo.clientHeight;
previewPane.isScrolling = true;
editor.scrollTo(null, ratio * editorHeight);
setTimeout(() => { previewPane.isScrolling = false; }, 50);
}
});
// Toggle preview pane
const toggleBtn = document.getElementById('toggle-preview');
const editorContainer = document.querySelector('.editor-container');
const previewElement = document.querySelector('.preview-pane');
toggleBtn.addEventListener('click', function() {
previewElement.classList.toggle('hidden');
if (previewElement.classList.contains('hidden')) {
toggleBtn.innerHTML = '<i class="mdi mdi-eye-outline mr-2"></i> Show Preview';
} else {
toggleBtn.innerHTML = '<i class="mdi mdi-eye-off-outline mr-2"></i> Hide Preview';
updatePreview(); // Refresh preview when showing
}
});
// Save document
const saveButton = document.getElementById('save-document');
const titleInput = document.getElementById('doc-title');
const categorySelect = document.getElementById('doc-category');
const notification = document.getElementById('save-notification');
saveButton.addEventListener('click', function() {
console.log("Save button clicked");
const documentData = {
title: titleInput.value.trim() || 'Untitled Document',
content: editor.getValue(),
category_id: categorySelect.value,
tags: Array.from(document.querySelectorAll('.tag')).map(tag => tag.textContent.trim())
};
{% if document and document.id %}
documentData.id = {{ document.id }};
console.log("Updating existing document:", documentData.id);
{% else %}
console.log("Creating new document");
{% endif %}
console.log("Sending document data:", documentData);
fetch('/api/document', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify(documentData)
})
.then(response => {
if (!response.ok) {
throw new Error('Server responded with an error: ' + response.status);
}
return response.json();
})
.then(data => {
console.log("Document saved successfully:", data);
// Show save notification
notification.classList.remove('translate-y-16', 'opacity-0');
// Hide notification after 3 seconds
setTimeout(() => {
notification.classList.add('translate-y-16', 'opacity-0');
}, 3000);
// If this is a new document, redirect to edit page
{% if not document %}
if (data.id) {
console.log("Redirecting to edit page for new document:", data.id);
window.location.href = `/document/${data.id}/edit`;
}
{% endif %}
})
.catch(error => {
console.error('Error saving document:', error);
alert('Error saving document: ' + error.message);
});
});
// Tags functionality
const tagInput = document.getElementById('doc-tags');
const tagList = document.querySelector('.tag-list');
function addTag(tagName) {
const tag = document.createElement('span');
tag.className = 'tag px-2 py-1 bg-primary/20 text-primary rounded-full text-xs flex items-center';
tag.innerHTML = `${tagName}<span class="remove-tag ml-1 cursor-pointer">&times;</span>`;
// Add remove functionality
tag.querySelector('.remove-tag').addEventListener('click', function() {
tag.remove();
});
tagList.appendChild(tag);
tagInput.value = '';
}
tagInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const tagName = tagInput.value.trim();
if (tagName) {
// Check for duplicate
const existingTags = Array.from(document.querySelectorAll('.tag')).map(tag =>
tag.textContent.trim().toLowerCase());
if (!existingTags.includes(tagName.toLowerCase())) {
addTag(tagName);
}
}
}
});
// Setup existing tag removal
document.querySelectorAll('.remove-tag').forEach(btn => {
btn.addEventListener('click', function() {
this.parentElement.remove();
});
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,278 @@
{% extends "base.html" %}
{% block title %}{{ document.title }} - Vim Docs{% endblock %}
{% block header_title %}{{ document.title }}{% endblock %}
{% block header_actions %}
<a href="{{ url_for('main.edit_document', doc_id=document.id) }}" class="button primary">
<i class="mdi mdi-pencil"></i> Edit
</a>
<a href="{{ url_for('main.export_document', doc_id=document.id) }}" class="button">
<i class="mdi mdi-download"></i> Export
</a>
{% endblock %}
{% block extra_css %}
<!-- GitHub Markdown CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.2.0/github-markdown-dark.min.css">
<style>
.document-view {
padding: 20px;
background-color: #0d1117;
}
.document-metadata {
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
}
.metadata-item {
margin-right: 15px;
color: var(--text-muted);
font-size: 0.9em;
display: flex;
align-items: center;
}
.metadata-item i {
margin-right: 5px;
}
.document-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.document-tag {
background: var(--accent-color);
color: var(--bg-color);
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
transition: transform 0.2s ease;
}
.document-tag:hover {
transform: translateY(-2px);
}
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
color: #c9d1d9;
}
/* GitHub-style admonitions/alerts */
.markdown-body .admonition {
padding: 1rem;
border-left: 4px solid;
margin: 1em 0;
border-radius: 6px;
background-color: rgba(175, 184, 193, 0.2);
}
.markdown-body .admonition-title {
font-weight: 600;
margin-top: 0;
}
.markdown-body .admonition-note {
border-color: #2b6eff;
background-color: rgba(43, 110, 255, 0.1);
}
.markdown-body .admonition-note .admonition-title {
color: #2b6eff;
}
.markdown-body .admonition-tip {
border-color: #3fb950;
background-color: rgba(63, 185, 80, 0.1);
}
.markdown-body .admonition-tip .admonition-title {
color: #3fb950;
}
.markdown-body .admonition-important {
border-color: #a371f7;
background-color: rgba(163, 113, 247, 0.1);
}
.markdown-body .admonition-important .admonition-title {
color: #a371f7;
}
.markdown-body .admonition-warning {
border-color: #d29922;
background-color: rgba(210, 153, 34, 0.1);
}
.markdown-body .admonition-warning .admonition-title {
color: #d29922;
}
.markdown-body .admonition-caution {
border-color: #f85149;
background-color: rgba(248, 81, 73, 0.1);
}
.markdown-body .admonition-caution .admonition-title {
color: #f85149;
}
/* Regular blockquotes */
.markdown-body blockquote {
padding: 0.5rem 1rem;
color: #8b949e;
border-left: 0.25em solid #30363d;
margin: 1em 0;
background-color: rgba(55, 65, 81, 0.1);
}
.markdown-body blockquote > :first-child {
margin-top: 0;
}
.markdown-body blockquote > :last-child {
margin-bottom: 0;
}
</style>
{% endblock %}
{% block content %}
<div class="document-view">
<div class="document-metadata">
<div class="metadata-item">
<i class="mdi mdi-calendar"></i>
Created: {{ document.created_date.strftime('%b %d, %Y') }}
</div>
<div class="metadata-item">
<i class="mdi mdi-update"></i>
Updated: {{ document.updated_date.strftime('%b %d, %Y') }}
</div>
{% if document.category %}
<div class="metadata-item">
<i class="mdi {{ document.category.icon }}"></i>
{{ document.category.name }}
</div>
{% endif %}
{% if document.tags %}
<div class="metadata-item document-tags">
<i class="mdi mdi-tag-multiple"></i>
{% for tag in document.tags %}
<span class="document-tag">{{ tag.name }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="markdown-body" id="document-content" data-content="{{ document.content|tojson|safe }}">
<!-- Content will be rendered here -->
</div>
</div>
{% endblock %}
{% block extra_js %}
<!-- Marked.js for Markdown parsing -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Markdown rendering configuration
marked.setOptions({
renderer: new marked.Renderer(),
gfm: true,
tables: true,
breaks: false,
pedantic: false,
sanitize: false,
smartLists: true,
smartypants: false
});
// Custom renderer for GitHub-style alert blocks
const renderer = new marked.Renderer();
// Fix the link renderer
const originalLink = renderer.link;
renderer.link = function(href, title, text) {
const html = originalLink.call(this, href, title, text);
return html.replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
};
// Fix the blockquote renderer for GitHub-style alert blocks
const originalBlockquote = renderer.blockquote;
renderer.blockquote = function(quote) {
// Extract text without p tags
const text = quote.replace(/<\/?p>/g, '');
// Pattern for GitHub-style alerts: > [!NOTE], > [!WARNING], etc.
const admonitionRegex = /^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION|DANGER)\]\s*([\s\S]*)/i;
const match = text.match(admonitionRegex);
if (match) {
const type = match[1].toLowerCase();
const title = match[1].charAt(0).toUpperCase() + match[1].slice(1).toLowerCase();
const content = match[2] ? match[2].trim() : '';
return `<div class="admonition admonition-${type}">
<p class="admonition-title">${title}</p>
<p>${content}</p>
</div>`;
}
// Fall back to the original blockquote renderer
return originalBlockquote.call(this, quote);
};
// Apply custom renderer
marked.use({ renderer: renderer });
// Render markdown content
const documentContent = document.getElementById('document-content');
const markdownContent = documentContent.getAttribute('data-content');
console.log("Raw content attribute:", markdownContent);
try {
// Parse the JSON-encoded content (Flask's tojson filter wraps content in quotes)
const decodedContent = JSON.parse(markdownContent);
console.log("Content length:", decodedContent.length);
// Render the markdown directly without additional checks
documentContent.innerHTML = marked.parse(decodedContent);
} catch (e) {
console.error("Error rendering document:", e);
documentContent.innerHTML = '<div class="text-center p-8 text-red-500">Error displaying document content.</div>';
}
// Add linkification for dynamic links
document.querySelectorAll('#document-content a[href^="#doc:"]').forEach(link => {
const docId = link.getAttribute('href').replace('#doc:', '');
link.setAttribute('href', `/document/${docId}`);
// Don't open internal doc links in new tab
link.removeAttribute('target');
link.removeAttribute('rel');
});
// Add keyboard shortcuts
document.addEventListener('keydown', function(e) {
// 'e' to edit the current document
if (e.key === 'e' && !e.ctrlKey && !e.metaKey && !e.altKey &&
!(e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) {
e.preventDefault();
window.location.href = "{{ url_for('main.edit_document', doc_id=document.id) }}";
}
});
});
</script>
{% endblock %}

400
app/templates/index.html Normal file
View file

@ -0,0 +1,400 @@
{% extends "base.html" %}
{% block title %}Dashboard - Vim Docs{% endblock %}
{% block header_title %}Dashboard{% endblock %}
{% block content %}
<div class="space-y-8">
<!-- Welcome banner -->
<div class="bg-gray-800 rounded-lg overflow-hidden shadow-lg">
<div class="p-6">
<div class="flex items-start">
<div class="flex-1">
<h1 class="text-2xl font-bold text-white mb-2">Welcome back, {{ current_user.username }}!</h1>
<p class="text-gray-400 mb-4">Manage your Vim and coding documentation with ease.</p>
<div class="flex space-x-3">
<a href="{{ url_for('main.new_document') }}" class="inline-flex items-center px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
<i class="mdi mdi-file-plus-outline mr-2"></i> New Document
</a>
<button id="category-btn" class="inline-flex items-center px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-600 transition-colors">
<i class="mdi mdi-folder-plus-outline mr-2"></i> New Category
</button>
</div>
</div>
<div class="hidden md:block">
<i class="mdi mdi-vim text-primary text-9xl opacity-20"></i>
</div>
</div>
</div>
</div>
<!-- Recent documents section -->
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-white">Recent Documents</h2>
<a href="#" class="text-primary hover:text-primary-light flex items-center text-sm">
View All <i class="mdi mdi-chevron-right ml-1"></i>
</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% if recent_docs %}
{% for doc in recent_docs %}
<div class="bg-gray-800 rounded-lg overflow-hidden shadow hover:shadow-lg transition-shadow">
<div class="p-5">
<div class="flex items-start justify-between">
<h3 class="text-white font-medium mb-2 truncate">
<a href="{{ url_for('main.view_document', doc_id=doc.id) }}" class="hover:text-primary transition-colors">
{{ doc.title }}
</a>
</h3>
<div class="dropdown relative ml-2">
<button class="icon-button p-1">
<i class="mdi mdi-dots-vertical"></i>
</button>
<div class="dropdown-menu hidden absolute right-0 mt-2 w-40 bg-gray-700 rounded-md shadow-lg z-10">
<a href="{{ url_for('main.edit_document', doc_id=doc.id) }}" class="block px-4 py-2 text-gray-300 hover:bg-gray-600 hover:text-white">
<i class="mdi mdi-pencil mr-2"></i> Edit
</a>
<a href="{{ url_for('main.export_document', doc_id=doc.id) }}" class="block px-4 py-2 text-gray-300 hover:bg-gray-600 hover:text-white">
<i class="mdi mdi-download mr-2"></i> Export
</a>
</div>
</div>
</div>
<div class="text-gray-400 text-sm mb-3 truncate">
{{ (doc.content[:100] + '...') if doc.content|length > 100 else doc.content }}
</div>
<div class="flex items-center justify-between mt-4">
<div class="text-xs text-gray-500">
<i class="mdi mdi-calendar-outline mr-1"></i> {{ doc.updated_date.strftime('%b %d, %Y') }}
</div>
{% if doc.category %}
<div class="flex items-center text-xs text-gray-500">
<i class="mdi {{ doc.category.icon }} mr-1"></i> {{ doc.category.name }}
</div>
{% endif %}
</div>
{% if doc.tags %}
<div class="flex flex-wrap gap-1 mt-3">
{% for tag in doc.tags %}
<span class="text-xs px-2 py-1 bg-primary/20 text-primary rounded-full">{{ tag.name }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="col-span-full bg-gray-800/50 rounded-lg p-8 text-center">
<i class="mdi mdi-file-document-outline text-6xl text-gray-700 mb-3"></i>
<h3 class="text-lg text-gray-400 mb-3">No documents yet</h3>
<p class="text-gray-500 mb-4">Create your first document to get started</p>
<a href="{{ url_for('main.new_document') }}" class="inline-flex items-center px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
<i class="mdi mdi-plus mr-2"></i> Create Document
</a>
</div>
{% endif %}
</div>
</div>
<!-- Categories section -->
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-white">My Categories</h2>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{% if categories %}
{% for category in categories %}
<div class="bg-gray-800 rounded-lg overflow-hidden shadow hover:shadow-lg transition-all hover:-translate-y-1 duration-200">
<a href="{{ url_for('main.view_category', category_id=category.id) }}" class="block p-5">
<div class="flex items-center mb-3">
<div class="w-10 h-10 rounded-md bg-primary/20 flex items-center justify-center text-primary mr-3">
<i class="mdi {{ category.icon }} text-2xl"></i>
</div>
<h3 class="text-white font-medium truncate">{{ category.name }}</h3>
</div>
{% if category.description %}
<p class="text-gray-400 text-sm mb-4 line-clamp-2">{{ category.description }}</p>
{% endif %}
<div class="flex items-center justify-between text-xs text-gray-500">
<span>{{ category.documents.count() }} document{{ '' if category.documents.count() == 1 else 's' }}</span>
<span>{{ category.children.count() }} subcategor{{ 'y' if category.children.count() == 1 else 'ies' }}</span>
</div>
</a>
</div>
{% endfor %}
<!-- Add category card -->
<div id="add-category-card" class="bg-gray-800/50 border-2 border-dashed border-gray-700 rounded-lg overflow-hidden hover:border-primary/50 hover:bg-gray-800/80 transition-all cursor-pointer">
<div class="p-5 h-full flex flex-col items-center justify-center text-center">
<div class="w-12 h-12 rounded-full bg-gray-700/50 flex items-center justify-center mb-3">
<i class="mdi mdi-folder-plus-outline text-2xl text-gray-500"></i>
</div>
<h3 class="text-gray-400 font-medium mb-1">New Category</h3>
<p class="text-gray-500 text-sm">Create a new category to organize your docs</p>
</div>
</div>
{% else %}
<div class="col-span-full bg-gray-800/50 rounded-lg p-8 text-center">
<i class="mdi mdi-folder-outline text-6xl text-gray-700 mb-3"></i>
<h3 class="text-lg text-gray-400 mb-3">No categories yet</h3>
<p class="text-gray-500 mb-4">Organize your documents by creating categories</p>
<button id="empty-add-category-btn" class="inline-flex items-center px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
<i class="mdi mdi-plus mr-2"></i> Create Category
</button>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Category Modal -->
<div id="category-modal" class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center hidden">
<div class="bg-gray-800 rounded-lg shadow-lg w-full max-w-md mx-4">
<div class="flex items-center justify-between p-4 border-b border-gray-700">
<h3 id="modal-title" class="text-lg font-medium text-white">Add Category</h3>
<button id="close-modal" class="text-gray-400 hover:text-white">
<i class="mdi mdi-close text-lg"></i>
</button>
</div>
<div class="p-6">
<form id="category-form" class="space-y-4">
<input type="hidden" id="category-id" value="">
<div>
<label for="category-name" class="block text-sm font-medium text-gray-400 mb-1">Name</label>
<input type="text" id="category-name" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
</div>
<div>
<label for="category-icon" class="block text-sm font-medium text-gray-400 mb-1">Icon</label>
<div class="flex items-center">
<input type="text" id="category-icon" value="mdi-folder-outline" class="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
<div class="ml-3 w-10 h-10 flex items-center justify-center text-primary bg-primary/10 rounded-md">
<i id="icon-preview" class="mdi mdi-folder-outline text-xl"></i>
</div>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<button type="button" data-icon="mdi-folder-outline" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
<i class="mdi mdi-folder-outline"></i>
</button>
<button type="button" data-icon="mdi-folder-text-outline" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
<i class="mdi mdi-folder-text-outline"></i>
</button>
<button type="button" data-icon="mdi-code-braces" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
<i class="mdi mdi-code-braces"></i>
</button>
<button type="button" data-icon="mdi-database" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
<i class="mdi mdi-database"></i>
</button>
<button type="button" data-icon="mdi-web" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
<i class="mdi mdi-web"></i>
</button>
<button type="button" data-icon="mdi-book-outline" class="icon-select w-8 h-8 flex items-center justify-center bg-gray-700 rounded-md text-primary hover:bg-gray-600">
<i class="mdi mdi-book-outline"></i>
</button>
</div>
</div>
<div>
<label for="category-description" class="block text-sm font-medium text-gray-400 mb-1">Description (Optional)</label>
<input type="text" id="category-description" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
</div>
<div>
<label for="category-parent" class="block text-sm font-medium text-gray-400 mb-1">Parent Category</label>
<select id="category-parent" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
<option value="">None</option>
{% for category in categories %}
<option value="{{ category.id }}">{{ category.name }}</option>
{% endfor %}
</select>
</div>
<div class="pt-4 flex justify-end space-x-3">
<button type="button" id="cancel-btn" class="px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-600 transition-colors">
Cancel
</button>
<button type="submit" class="px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
Save
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Category Modal JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
console.log("DOM content loaded, initializing category modal");
// Category modal functionality
const modal = document.getElementById('category-modal');
const addCategoryBtn = document.getElementById('category-btn');
const addCategoryCard = document.getElementById('add-category-card');
const emptyAddBtn = document.getElementById('empty-add-category-btn');
const closeModalBtn = document.getElementById('close-modal');
const cancelBtn = document.getElementById('cancel-btn');
const form = document.getElementById('category-form');
const iconInput = document.getElementById('category-icon');
const iconPreview = document.getElementById('icon-preview');
const iconSelectBtns = document.querySelectorAll('.icon-select');
// Open modal function
function openModal() {
console.log("Opening category modal");
modal.classList.remove('hidden');
document.getElementById('category-name').focus();
}
// Close modal function
function closeModal() {
console.log("Closing category modal");
modal.classList.add('hidden');
form.reset();
document.getElementById('category-id').value = '';
iconPreview.className = 'mdi mdi-folder-outline text-xl';
}
// Update icon preview
if (iconInput) {
iconInput.addEventListener('input', function() {
iconPreview.className = 'mdi ' + this.value + ' text-xl';
});
}
// Icon selection
iconSelectBtns.forEach(btn => {
btn.addEventListener('click', function() {
const icon = this.getAttribute('data-icon');
iconInput.value = icon;
iconPreview.className = 'mdi ' + icon + ' text-xl';
// Highlight selected icon
iconSelectBtns.forEach(b => b.classList.remove('ring-2', 'ring-primary'));
this.classList.add('ring-2', 'ring-primary');
});
});
// Event listeners for opening the modal
console.log("Setting up event listeners");
if (addCategoryBtn) {
console.log("Found category-btn, adding click handler");
addCategoryBtn.addEventListener('click', function(e) {
e.preventDefault();
openModal();
});
}
if (addCategoryCard) {
console.log("Found add-category-card, adding click handler");
addCategoryCard.addEventListener('click', function(e) {
e.preventDefault();
openModal();
});
}
if (emptyAddBtn) {
console.log("Found empty-add-category-btn, adding click handler");
emptyAddBtn.addEventListener('click', function(e) {
e.preventDefault();
openModal();
});
}
// Event listeners for closing the modal
if (closeModalBtn) {
closeModalBtn.addEventListener('click', closeModal);
}
if (cancelBtn) {
cancelBtn.addEventListener('click', closeModal);
}
// Add click outside to close
window.addEventListener('click', function(e) {
if (e.target === modal) {
closeModal();
}
});
// Form submission
if (form) {
form.addEventListener('submit', function(e) {
e.preventDefault();
console.log("Category form submitted");
const categoryData = {
name: document.getElementById('category-name').value,
icon: document.getElementById('category-icon').value,
description: document.getElementById('category-description').value,
parent_id: document.getElementById('category-parent').value || null
};
const categoryId = document.getElementById('category-id').value;
if (categoryId) {
categoryData.id = categoryId;
}
console.log("Sending category data:", categoryData);
fetch('/api/category', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify(categoryData)
})
.then(response => {
if (!response.ok) {
throw new Error('Server responded with an error: ' + response.status);
}
return response.json();
})
.then(data => {
console.log("Category saved successfully:", data);
// Reload the page to show the new category
window.location.reload();
})
.catch(error => {
console.error('Error saving category:', error);
alert('Error saving category: ' + error.message);
});
});
}
// Dropdown functionality for document cards
document.querySelectorAll('.dropdown button').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const menu = this.nextElementSibling;
menu.classList.toggle('hidden');
// Close other open dropdowns
document.querySelectorAll('.dropdown-menu:not(.hidden)').forEach(m => {
if (m !== menu) m.classList.add('hidden');
});
});
});
// Close dropdowns when clicking outside
document.addEventListener('click', function() {
document.querySelectorAll('.dropdown-menu:not(.hidden)').forEach(menu => {
menu.classList.add('hidden');
});
});
});
</script>
{% endblock %}