This commit is contained in:
pika 2025-04-17 15:36:24 +02:00
parent f5c8e9ee23
commit 3a16f266da
15 changed files with 511 additions and 169 deletions

View file

@ -21,6 +21,108 @@ class Config:
PERMANENT_SESSION_LIFETIME = timedelta(hours=12) PERMANENT_SESSION_LIFETIME = timedelta(hours=12)
SESSION_TYPE = 'filesystem' SESSION_TYPE = 'filesystem'
def init_db(app):
"""Initialize the database and create tables if they don't exist."""
with app.app_context():
db.create_all()
from app.models.user import User
# Create a demo user if no users exist
if User.query.count() == 0:
from app.models.document import Document, Category, Tag
from werkzeug.security import generate_password_hash
print('Creating demo user...')
# Create demo user
demo_user = User(username='demo')
demo_user.set_password('password')
db.session.add(demo_user)
db.session.flush() # To get the user ID
# Create a root category for the demo user
root_category = Category(
name='My Documents',
icon='mdi-folder',
description='Default document category',
user_id=demo_user.id,
is_root=True
)
db.session.add(root_category)
# Create some sample categories
categories = [
Category(
name='Vim Commands',
icon='mdi-vim',
user_id=demo_user.id,
description='Essential Vim commands and shortcuts'
),
Category(
name='Flask Development',
icon='mdi-flask',
user_id=demo_user.id,
description='Flask web development notes'
),
Category(
name='Python Snippets',
icon='mdi-language-python',
user_id=demo_user.id,
description='Useful Python code snippets'
)
]
for category in categories:
db.session.add(category)
# Create a sample document
sample_doc = Document(
title='Getting Started with Vim',
content="""# Getting Started with Vim
## Basic Commands
### Movement
- `h` - move left
- `j` - move down
- `k` - move up
- `l` - move right
### Modes
- `i` - enter insert mode
- `Esc` - return to normal mode
- `v` - enter visual mode
- `:` - enter command mode
> Vim has a steep learning curve, but it's worth it!
> [!TIP]
> Use `vimtutor` to learn Vim basics interactively.
> [!NOTE]
> Vim is available on almost all Unix-like systems.
""",
user_id=demo_user.id,
category_id=categories[0].id
)
db.session.add(sample_doc)
# Create some tags
tags = [
Tag(name='vim', user_id=demo_user.id, color='#50fa7b'),
Tag(name='editor', user_id=demo_user.id, color='#bd93f9'),
Tag(name='tutorial', user_id=demo_user.id, color='#ff79c6')
]
for tag in tags:
db.session.add(tag)
# Associate tags with the document
sample_doc.tags = tags
# Commit all changes
db.session.commit()
print('Demo user and sample data created successfully!')
def create_app(config_class=Config): def create_app(config_class=Config):
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(config_class) app.config.from_object(config_class)
@ -51,6 +153,9 @@ def create_app(config_class=Config):
# Create app instance # Create app instance
app = create_app() app = create_app()
# Initialize database
init_db(app)
# Import models after db initialization to avoid circular imports # Import models after db initialization to avoid circular imports
from app.models.document import Document, Category, Tag from app.models.document import Document, Category, Tag
from app.models.user import User from app.models.user import User

View file

@ -45,9 +45,9 @@ def signup():
# Create root category for the user # Create root category for the user
root_category = Category( root_category = Category(
name='Library', name='', # Empty name for the root
icon='mdi-bookshelf', icon='mdi-folder-outline',
description='General storage for documents and categories', description='Default container for documents and categories',
user_id=user.id, user_id=user.id,
is_root=True is_root=True
) )

View file

@ -48,10 +48,18 @@ class Category(db.Model):
def __repr__(self): def __repr__(self):
return f'<Category {self.name}>' return f'<Category {self.name}>'
@property
def display_name(self):
"""Return a display name for the category, showing 'Root' for the root category with empty name"""
if self.is_root and not self.name:
return "Root"
return self.name
def to_dict(self): def to_dict(self):
return { return {
'id': self.id, 'id': self.id,
'name': self.name, 'name': self.name or '', # Ensure name is never null
'display_name': self.display_name,
'icon': self.icon, 'icon': self.icon,
'description': self.description, 'description': self.description,
'parent_id': self.parent_id, 'parent_id': self.parent_id,

View file

@ -7,6 +7,7 @@ import json
from datetime import datetime from datetime import datetime
import io import io
from sqlalchemy import or_ from sqlalchemy import or_
import re
main = Blueprint('main', __name__) main = Blueprint('main', __name__)
@ -164,9 +165,9 @@ def save_document():
if not root_category: if not root_category:
# Create a root category if it doesn't exist # Create a root category if it doesn't exist
root_category = Category( root_category = Category(
name='Library', name='',
icon='mdi-bookshelf', icon='mdi-folder-outline',
description='General storage for documents and categories', description='Default container for documents and categories',
user_id=current_user.id, user_id=current_user.id,
is_root=True is_root=True
) )
@ -341,54 +342,64 @@ def delete_category(category_id):
return jsonify({'success': True}) return jsonify({'success': True})
@main.route('/api/search', methods=['GET']) @main.route('/search')
@login_required
def search_documents(): def search_documents():
"""Search for documents by title, content, or tags"""
query = request.args.get('q', '') query = request.args.get('q', '')
user_id = current_user.id
if not query or len(query) < 2: if not query:
return jsonify({'results': []}) return jsonify([])
# Search in title, content, and tags for current user's documents only # Parse hashtags from the query
docs = Document.query.filter( hashtags = re.findall(r'#(\w+)', query)
Document.user_id == current_user.id, # Remove hashtags from the main query text
or_( cleaned_query = re.sub(r'#\w+', '', query).strip()
Document.title.ilike(f'%{query}%'),
Document.content.ilike(f'%{query}%'), # Base query for documents
Document.tags.any(Tag.name.ilike(f'%{query}%')) base_query = Document.query.filter_by(user_id=user_id)
)
).limit(10).all()
results = [] results = []
for doc in docs:
# Find match in content # If we have a cleaned query (non-hashtag part), search by title and content
match = None if cleaned_query:
if query.lower() in doc.content.lower(): title_content_results = base_query.filter(
# Find the sentence containing the match or_(
content_lower = doc.content.lower() Document.title.ilike(f'%{cleaned_query}%'),
query_pos = content_lower.find(query.lower()) Document.content.ilike(f'%{cleaned_query}%')
)
# Get a snippet around the match ).all()
start = max(0, content_lower.rfind('.', 0, query_pos) + 1) results.extend(title_content_results)
end = content_lower.find('.', query_pos)
if end == -1: # If we have hashtags, search by tags (OR operation between tags)
end = min(len(doc.content), query_pos + 200) if hashtags:
tag_results = base_query.join(document_tags).join(Tag).filter(
match = doc.content[start:end].strip() Tag.name.in_(hashtags)
).all()
# Get category name # Add unique tag results to the results list
category_name = doc.category.name if doc.category else None for doc in tag_results:
if doc not in results:
results.append({ results.append(doc)
# If no specific search criteria (cleaned query or hashtags), return empty list
if not cleaned_query and not hashtags:
return jsonify([])
# Convert to list of dictionaries for JSON response
docs_list = []
for doc in results:
doc_dict = {
'id': doc.id, 'id': doc.id,
'title': doc.title, 'title': doc.title,
'category': category_name, 'preview': doc.content[:150] + '...' if len(doc.content) > 150 else doc.content,
'tags': [tag.name for tag in doc.tags], 'category_id': doc.category_id,
'match': match 'created_at': doc.created_at.strftime('%Y-%m-%d %H:%M'),
}) 'updated_at': doc.updated_at.strftime('%Y-%m-%d %H:%M') if doc.updated_at else None,
'tags': [tag.name for tag in doc.tags]
}
docs_list.append(doc_dict)
return jsonify({'results': results}) return jsonify(docs_list)
@main.route('/api/tags', methods=['GET']) @main.route('/api/tags', methods=['GET'])
@login_required @login_required
@ -433,8 +444,10 @@ def new_category():
if parent_id: if parent_id:
parent = Category.query.filter_by(id=parent_id, user_id=current_user.id).first_or_404() parent = Category.query.filter_by(id=parent_id, user_id=current_user.id).first_or_404()
return render_template('category_edit.html', category=None, parent=parent)
return render_template('category_edit.html', category=None, parent=parent) else:
# No parent specified, creating a top-level category
return render_template('category_edit.html', category=None, parent=None)
@main.route('/category/<int:category_id>/edit', methods=['GET']) @main.route('/category/<int:category_id>/edit', methods=['GET'])
@login_required @login_required

View file

@ -35,8 +35,7 @@
/* Active view styling */ /* Active view styling */
.active-view { .active-view {
background-color: rgba(76, 175, 80, 0.2); /* Primary color with opacity */ @apply bg-primary/20 text-primary;
color: #4CAF50; /* Primary color */
} }
/* Fix for sidebar hiding */ /* Fix for sidebar hiding */
@ -78,6 +77,16 @@
z-index: 10; z-index: 10;
} }
/* Sidebar section headers */
.sidebar-section-header {
font-size: 0.75rem;
text-transform: uppercase;
color: rgba(156, 163, 175, 0.7);
letter-spacing: 0.05em;
padding: 0.5rem 1rem 0.25rem;
margin-top: 0.5rem;
}
/* Drag and drop styles */ /* Drag and drop styles */
.drop-target { .drop-target {
background-color: rgba(80, 250, 123, 0.15); background-color: rgba(80, 250, 123, 0.15);
@ -172,4 +181,70 @@
.markdown-body .admonition-danger .admonition-title { .markdown-body .admonition-danger .admonition-title {
color: #cf222e; color: #cf222e;
}
/* Modern Sidebar Styling */
aside {
background-color: #1a1b26;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.2);
}
/* Reduce vertical spacing of sidebar items */
aside nav ul li {
margin: 0.15rem 0;
}
/* Better styling for sidebar links */
aside nav ul li a {
padding: 0.4rem 0.75rem;
border-radius: 0.25rem;
transition: all 0.15s ease;
}
aside nav ul li a:hover {
background-color: rgba(255, 255, 255, 0.05);
}
/* Remove the border-left styling in category trees */
aside nav ul.ml-3.pl-3.border-l.border-gray-700,
.ml-3.pl-3.border-l.border-gray-700,
.ml-2.pl-2.border-l.border-gray-700 {
margin-left: 1.25rem !important;
padding-left: 0 !important;
border-left: none !important;
}
/* Better nested document styling */
.document-item a,
.category-item a {
font-size: 0.875rem;
display: flex;
align-items: center;
padding: 0.35rem 0.5rem;
}
/* Cleaner category headers */
aside nav ul li div.block.font-medium {
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.5);
margin-top: 1rem;
}
/* Make the toggle buttons more subtle */
.toggle-btn {
opacity: 0.6;
transition: all 0.15s ease;
}
.toggle-btn:hover {
opacity: 1;
}
/* Better styling for the primary action button */
aside nav ul li a.bg-primary {
margin-top: 0.5rem;
box-shadow: 0 3px 8px rgba(80, 250, 123, 0.2);
} }

View file

@ -56,49 +56,53 @@
<div class="flex min-h-screen {% if request.cookies.get('sidebar_collapsed') == 'true' %}sidebar-hidden{% endif %}"> <div class="flex min-h-screen {% if request.cookies.get('sidebar_collapsed') == 'true' %}sidebar-hidden{% endif %}">
<!-- Sidebar --> <!-- 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"> <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"> <div class="p-4 border-b border-gray-700/30 flex items-center">
<h1 class="text-xl font-semibold text-white 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 <i class="mdi mdi-vim text-primary text-2xl mr-2"></i> Vim Docs
</h1> </h1>
</div> </div>
<nav class="py-4"> <nav class="py-2">
<ul class="list-none"> <ul class="list-none">
<li class="my-1"> <li>
<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' }}"> <a href="{{ url_for('main.index') }}" class="flex items-center px-4 py-2 text-gray-300 hover:text-primary hover:bg-gray-700/50 rounded-md transition-all {{ 'bg-primary/10 text-primary' if request.endpoint == 'main.index' }}">
<i class="mdi mdi-home mr-3"></i> Home <i class="mdi mdi-home mr-3"></i> Home
</a> </a>
</li> </li>
<li class="my-4"> <li class="mt-3">
<div class="block font-medium text-gray-400 px-4 py-2 flex items-center"> <a href="#" id="root-category-link" class="flex items-center justify-between px-4 py-2 text-gray-300 hover:text-primary hover:bg-gray-700/50 rounded-md transition-all">
<i class="mdi mdi-folder-multiple-outline mr-3"></i> Categories <div class="flex items-center">
</div> <i class="mdi mdi-folder-outline mr-3"></i>
<ul class="ml-3 pl-3 border-l border-gray-700 my-1" id="category-tree"> <span>Files & Categories</span>
</div>
<i class="mdi mdi-chevron-down text-sm"></i>
</a>
<ul class="ml-3 pt-1" id="category-tree">
<!-- Categories will be loaded here via JS --> <!-- Categories will be loaded here via JS -->
</ul> </ul>
</li> </li>
<li class="my-4"> <li class="mt-3">
<div class="block font-medium text-gray-400 px-4 py-2 flex items-center"> <div class="block font-medium text-gray-400 px-4 py-1 flex items-center">
<i class="mdi mdi-file-document-multiple-outline mr-3"></i> Documents <i class="mdi mdi-file-document-multiple-outline mr-3"></i> Documents
</div> </div>
<ul class="ml-3 pl-3 border-l border-gray-700 my-1"> <ul class="ml-3 pt-1">
<li> <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' }}"> <a href="{{ url_for('main.new_document') }}" class="flex items-center py-1 px-2 text-gray-400 hover:text-primary hover:bg-gray-700/30 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 <i class="mdi mdi-plus-circle-outline mr-2 text-sm"></i> New Document
</a> </a>
</li> </li>
<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"> <a href="{{ url_for('main.index') }}?view=recent" class="flex items-center py-1 px-2 text-gray-400 hover:text-primary hover:bg-gray-700/30 rounded transition-colors">
<i class="mdi mdi-clock-outline mr-2 text-sm"></i> Recent Documents <i class="mdi mdi-clock-outline mr-2 text-sm"></i> Recent Documents
</a> </a>
</li> </li>
</ul> </ul>
</li> </li>
<li class="my-1"> <li class="mt-4 mb-2 px-3">
<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"> <a href="{{ url_for('main.new_document') }}" class="flex items-center justify-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 <i class="mdi mdi-plus-circle mr-2"></i> New Document
</a> </a>
</li> </li>
</ul> </ul>
@ -188,6 +192,55 @@
}); });
} }
// Root category toggle
const rootCategoryLink = document.getElementById('root-category-link');
const categoryTree = document.getElementById('category-tree');
if (rootCategoryLink && categoryTree) {
// Initialize - expand by default
rootCategoryLink.querySelector('i.mdi-chevron-down').classList.add('rotate-180');
rootCategoryLink.addEventListener('click', function(e) {
// Only handle the toggle if clicking on the chevron icon
if (e.target.classList.contains('mdi-chevron-down') || e.target.parentElement.classList.contains('mdi-chevron-down')) {
e.preventDefault();
e.stopPropagation();
// Toggle category tree visibility
if (categoryTree.classList.contains('hidden')) {
categoryTree.classList.remove('hidden');
rootCategoryLink.querySelector('i.mdi-chevron-down').classList.add('rotate-180');
} else {
categoryTree.classList.add('hidden');
rootCategoryLink.querySelector('i.mdi-chevron-down').classList.remove('rotate-180');
}
// Save preference in localStorage
localStorage.setItem('rootCategoryExpanded', !categoryTree.classList.contains('hidden'));
} else {
// If not clicking on the chevron, we need to load the root category
fetch('/api/categories')
.then(response => response.json())
.then(categories => {
const rootCategory = categories.find(c => c.is_root);
if (rootCategory) {
window.location.href = `/category/${rootCategory.id}`;
}
})
.catch(error => {
console.error('Error fetching root category:', error);
});
}
});
// Load saved preference
const rootExpanded = localStorage.getItem('rootCategoryExpanded') !== 'false';
if (!rootExpanded) {
categoryTree.classList.add('hidden');
rootCategoryLink.querySelector('i.mdi-chevron-down').classList.remove('rotate-180');
}
}
// Load categories // Load categories
loadCategories(); loadCategories();
@ -205,20 +258,155 @@
categoryTree.innerHTML = ''; // Clear existing items categoryTree.innerHTML = ''; // Clear existing items
if (categories.length === 0) { if (categories.length === 0) {
categoryTree.innerHTML = '<li class="px-4 py-2 text-gray-500">No categories found</li>'; categoryTree.innerHTML = '<li class="px-4 py-2 text-gray-500">No items found</li>';
return; return;
} }
categories.forEach(category => { // First add documents without categories (directly in root)
categoryTree.appendChild(createCategoryItem(category)); const rootCategory = categories.find(c => c.is_root);
}); if (rootCategory && rootCategory.documents && rootCategory.documents.length > 0) {
// Create a section header for root documents
const docHeader = document.createElement('div');
docHeader.className = 'text-xs uppercase text-gray-500 font-medium px-2 py-1 mt-2';
docHeader.textContent = 'Files';
categoryTree.appendChild(docHeader);
// Create a container for documents
const docsContainer = document.createElement('div');
docsContainer.className = 'mb-2';
categoryTree.appendChild(docsContainer);
const documentsUl = document.createElement('ul');
documentsUl.className = 'py-1 space-y-0.5';
docsContainer.appendChild(documentsUl);
rootCategory.documents.forEach(docId => {
// Fetch document details and add to the tree
fetch(`/api/document/${docId}`)
.then(response => response.json())
.then(doc => {
const docLi = document.createElement('li');
docLi.className = 'document-item relative group';
const docLink = document.createElement('a');
docLink.href = `/document/${doc.id}`;
docLink.className = 'flex items-center py-1 px-2 text-gray-400 hover:text-primary hover:bg-gray-700/30 rounded transition-colors truncate';
docLink.innerHTML = `<i class="mdi mdi-file-document-outline mr-2 text-sm"></i> <span class="truncate">${doc.title}</span>`;
// Add drag functionality
docLink.draggable = true;
docLink.dataset.docId = doc.id;
docLink.addEventListener('dragstart', function(e) {
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'document',
id: doc.id,
title: doc.title
}));
docLi.classList.add('dragging');
});
docLink.addEventListener('dragend', function() {
docLi.classList.remove('dragging');
});
// Add document actions
const docActions = createDocumentActions(doc, docLi, rootCategory.id);
docLi.appendChild(docLink);
docLi.appendChild(docActions);
documentsUl.appendChild(docLi);
})
.catch(error => {
console.error(`Error fetching document ${docId}:`, error);
});
});
}
// If there are any non-root categories, add a section header
const nonRootCategories = categories.filter(c => !c.is_root);
if (nonRootCategories.length > 0) {
const catHeader = document.createElement('div');
catHeader.className = 'text-xs uppercase text-gray-500 font-medium px-2 py-1 mt-3';
catHeader.textContent = 'Categories';
categoryTree.appendChild(catHeader);
// Then add all non-root categories
nonRootCategories.forEach(category => {
categoryTree.appendChild(createCategoryItem(category));
});
}
}) })
.catch(error => { .catch(error => {
console.error('Error loading categories:', error); console.error('Error loading categories:', error);
categoryTree.innerHTML = '<li class="px-4 py-2 text-red-500">Error loading categories</li>'; categoryTree.innerHTML = '<li class="px-4 py-2 text-red-500">Error loading items</li>';
}); });
} }
function createDocumentActions(doc, docLi, categoryId) {
const docActions = document.createElement('div');
docActions.className = 'actions absolute right-0 hidden group-hover:flex items-center bg-gray-800/90 px-1 rounded-sm';
// Edit document button
const editBtn = document.createElement('button');
editBtn.className = 'p-1 text-gray-500 hover:text-primary rounded transition-colors';
editBtn.title = 'Edit document';
editBtn.innerHTML = '<i class="mdi mdi-pencil-outline text-sm"></i>';
editBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
window.location.href = `/document/${doc.id}/edit`;
});
docActions.appendChild(editBtn);
// Delete document button
const deleteBtn = document.createElement('button');
deleteBtn.className = 'p-1 text-gray-500 hover:text-red-500 rounded transition-colors';
deleteBtn.title = 'Delete document';
deleteBtn.innerHTML = '<i class="mdi mdi-delete-outline text-sm"></i>';
deleteBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
// Show confirmation dialog
if (confirm(`Are you sure you want to delete "${doc.title}"? This cannot be undone.`)) {
// Send delete request
fetch(`/api/document/${doc.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
}
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to delete');
}
return response.json();
})
.then(data => {
// Remove the document from the DOM
docLi.remove();
// Show notification
showNotification('Document deleted successfully');
// If we're on the document's page, redirect to the category or home
const currentPath = window.location.pathname;
if (currentPath === `/document/${doc.id}` || currentPath === `/document/${doc.id}/edit`) {
window.location.href = categoryId ? `/category/${categoryId}` : '/';
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error deleting document', 'error');
});
}
});
docActions.appendChild(deleteBtn);
return docActions;
}
function createCategoryItem(category) { function createCategoryItem(category) {
const li = document.createElement('li'); const li = document.createElement('li');
li.className = 'category-item my-1'; li.className = 'category-item my-1';
@ -248,7 +436,7 @@
// Create the link to view the category // Create the link to view the category
const a = document.createElement('a'); const a = document.createElement('a');
a.href = `/category/${category.id}`; a.href = `/category/${category.id}`;
let categoryClass = 'flex-grow flex items-center py-1 px-2 text-gray-400 hover:text-primary rounded transition-colors overflow-hidden'; let categoryClass = 'flex-grow flex items-center py-1 px-2 text-gray-400 hover:text-primary hover:bg-gray-700/30 rounded transition-colors overflow-hidden';
// Special styling for root // Special styling for root
if (category.is_root) { if (category.is_root) {
@ -264,7 +452,7 @@
// Create actions container that appears on hover // Create actions container that appears on hover
const actionsContainer = document.createElement('div'); const actionsContainer = document.createElement('div');
actionsContainer.className = 'actions absolute right-0 hidden group-hover:flex items-center bg-gray-800 px-1'; actionsContainer.className = 'actions absolute right-0 hidden group-hover:flex items-center bg-gray-800/90 px-1 rounded-sm';
categoryContainer.appendChild(actionsContainer); categoryContainer.appendChild(actionsContainer);
// Add document button - consistent across all views // Add document button - consistent across all views
@ -305,7 +493,7 @@
// Create the child container for documents and subcategories // Create the child container for documents and subcategories
const childrenContainer = document.createElement('div'); const childrenContainer = document.createElement('div');
childrenContainer.className = 'ml-2 pl-2 border-l border-gray-700 mt-1 mb-1 overflow-hidden transition-all duration-300'; childrenContainer.className = 'ml-4 mt-1 mb-1 overflow-hidden transition-all duration-300';
childrenContainer.style.display = 'none'; // Initially collapsed childrenContainer.style.display = 'none'; // Initially collapsed
li.appendChild(childrenContainer); li.appendChild(childrenContainer);
@ -357,7 +545,7 @@
// Add documents first // Add documents first
if (hasDocuments) { if (hasDocuments) {
const documentsUl = document.createElement('ul'); const documentsUl = document.createElement('ul');
documentsUl.className = 'py-1 space-y-1'; documentsUl.className = 'py-1 space-y-0.5';
childrenContainer.appendChild(documentsUl); childrenContainer.appendChild(documentsUl);
// Sort documents by name // Sort documents by name
@ -374,7 +562,7 @@
const docLink = document.createElement('a'); const docLink = document.createElement('a');
docLink.href = `/document/${doc.id}`; docLink.href = `/document/${doc.id}`;
docLink.className = 'flex items-center py-1 px-2 text-gray-400 hover:text-primary rounded transition-colors truncate'; docLink.className = 'flex items-center py-1 px-2 text-gray-400 hover:text-primary hover:bg-gray-700/30 rounded transition-colors truncate';
docLink.innerHTML = `<i class="mdi mdi-file-document-outline mr-2 text-sm"></i> <span class="truncate">${doc.title}</span>`; docLink.innerHTML = `<i class="mdi mdi-file-document-outline mr-2 text-sm"></i> <span class="truncate">${doc.title}</span>`;
// Add drag functionality // Add drag functionality
@ -394,66 +582,7 @@
}); });
// Add actions for documents // Add actions for documents
const docActions = document.createElement('div'); const docActions = createDocumentActions(doc, docLi, category.id);
docActions.className = 'actions absolute right-0 hidden group-hover:flex items-center bg-gray-800 px-1';
// Edit document button
const editBtn = document.createElement('button');
editBtn.className = 'p-1 text-gray-500 hover:text-primary rounded transition-colors';
editBtn.title = 'Edit document';
editBtn.innerHTML = '<i class="mdi mdi-pencil-outline text-sm"></i>';
editBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
window.location.href = `/document/${doc.id}/edit`;
});
docActions.appendChild(editBtn);
// Delete document button
const deleteBtn = document.createElement('button');
deleteBtn.className = 'p-1 text-gray-500 hover:text-red-500 rounded transition-colors';
deleteBtn.title = 'Delete document';
deleteBtn.innerHTML = '<i class="mdi mdi-delete-outline text-sm"></i>';
deleteBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
// Show confirmation dialog
if (confirm(`Are you sure you want to delete "${doc.title}"? This cannot be undone.`)) {
// Send delete request
fetch(`/api/document/${doc.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
}
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to delete');
}
return response.json();
})
.then(data => {
// Remove the document from the DOM
docLi.remove();
// Show notification
showNotification('Document deleted successfully');
// If we're on the document's page, redirect to the category or home
const currentPath = window.location.pathname;
if (currentPath === `/document/${doc.id}` || currentPath === `/document/${doc.id}/edit`) {
window.location.href = `/category/${category.id}`;
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error deleting document', 'error');
});
}
});
docActions.appendChild(deleteBtn);
docLi.appendChild(docLink); docLi.appendChild(docLink);
docLi.appendChild(docActions); docLi.appendChild(docActions);

View file

@ -1,21 +1,23 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ category.name }} - Vim Docs{% endblock %} {% block title %}{{ category.display_name }} - Vim Docs{% endblock %}
{% block header_title %} {% block header_title %}
<i class="mdi {{ category.icon }} mr-2"></i> {{ category.name }} <i class="mdi {{ category.icon }} mr-2"></i> {{ category.display_name }}
{% endblock %} {% endblock %}
{% block header_actions %} {% 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"> <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 <i class="mdi mdi-plus mr-2"></i> New Document
</a> </a>
<a href="{{ url_for('main.new_category') }}?parent_id={{ category.id }}" class="inline-flex items-center px-4 py-2 bg-primary/80 text-black rounded-md hover:bg-primary-dark transition-colors ml-2"> <a href="{{ url_for('main.new_category') }}{% if not category.is_root %}?parent_id={{ category.id }}{% endif %}" class="inline-flex items-center px-4 py-2 bg-primary/80 text-black rounded-md hover:bg-primary-dark transition-colors ml-2">
<i class="mdi mdi-folder-plus-outline mr-2"></i> New Subcategory <i class="mdi mdi-folder-plus-outline mr-2"></i> {% if category.is_root %}New Category{% else %}New Subcategory{% endif %}
</a> </a>
{% if not category.is_root %}
<a href="{{ url_for('main.edit_category', category_id=category.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"> <a href="{{ url_for('main.edit_category', category_id=category.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-pencil mr-2"></i> Edit Category <i class="mdi mdi-pencil mr-2"></i> Edit Category
</a> </a>
{% endif %}
<a href="{{ url_for('main.category_all_documents', category_id=category.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"> <a href="{{ url_for('main.category_all_documents', category_id=category.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-file-document-multiple-outline mr-2"></i> All Documents <i class="mdi mdi-file-document-multiple-outline mr-2"></i> All Documents
</a> </a>
@ -29,14 +31,18 @@
<div class="w-10 h-10 rounded-md bg-primary/20 flex items-center justify-center text-primary mr-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> <i class="mdi {{ category.icon }} text-2xl"></i>
</div> </div>
<h1 class="text-2xl font-bold text-white">{{ category.name }}</h1> <h1 class="text-2xl font-bold text-white">{{ category.display_name }}</h1>
</div> </div>
<div class="text-gray-400 mb-4"> <div class="text-gray-400 mb-4">
{% if category.description %} {% if category.description %}
{{ category.description }} {{ category.description }}
{% else %} {% else %}
{% if category.is_root %}
The default container for all your documents and categories
{% else %}
A category for organizing your documents A category for organizing your documents
{% endif %}
{% endif %} {% endif %}
</div> </div>
@ -46,10 +52,12 @@
<a href="{{ url_for('main.index') }}" class="hover:text-primary">Home</a> <a href="{{ url_for('main.index') }}" class="hover:text-primary">Home</a>
{% if category.parent %} {% if category.parent %}
<span class="mx-2">/</span> <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> <a href="{{ url_for('main.view_category', category_id=category.parent.id) }}" class="hover:text-primary">{{ category.parent.display_name }}</a>
{% endif %} {% endif %}
{% if not category.is_root %}
<span class="mx-2">/</span> <span class="mx-2">/</span>
<span class="text-gray-400">{{ category.name }}</span> <span class="text-gray-400">{{ category.display_name }}</span>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -72,7 +80,7 @@
<div class="w-10 h-10 rounded-md bg-primary/20 flex items-center justify-center text-primary mr-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> <i class="mdi {{ subcategory.icon }} text-2xl"></i>
</div> </div>
<h3 class="text-white font-medium truncate">{{ subcategory.name }}</h3> <h3 class="text-white font-medium truncate">{{ subcategory.display_name }}</h3>
</div> </div>
{% if subcategory.description %} {% if subcategory.description %}
@ -94,7 +102,7 @@
<i class="mdi mdi-folder-plus-outline text-2xl text-gray-500"></i> <i class="mdi mdi-folder-plus-outline text-2xl text-gray-500"></i>
</div> </div>
<h3 class="text-gray-400 font-medium mb-1">New Subcategory</h3> <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> <p class="text-gray-500 text-sm">Add a subcategory to {{ category.display_name }}</p>
</a> </a>
</div> </div>
</div> </div>

View file

@ -8,9 +8,15 @@
<button id="save-button" class="inline-flex items-center px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors"> <button id="save-button" 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 <i class="mdi mdi-content-save mr-2"></i> Save
</button> </button>
{% if category %}
<a href="{{ url_for('main.view_category', category_id=category.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"> <a href="{{ url_for('main.view_category', category_id=category.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-arrow-left mr-2"></i> Back <i class="mdi mdi-arrow-left mr-2"></i> Back
</a> </a>
{% else %}
<a href="{{ url_for('main.index') }}" 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-arrow-left mr-2"></i> Back
</a>
{% endif %}
{% if category and not category.is_root %} {% if category and not category.is_root %}
<button id="delete-category-btn" class="inline-flex items-center px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-500 transition-colors ml-2"> <button id="delete-category-btn" class="inline-flex items-center px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-500 transition-colors ml-2">
<i class="mdi mdi-delete mr-2"></i> Delete <i class="mdi mdi-delete mr-2"></i> Delete
@ -91,7 +97,7 @@
<label for="category-parent" class="block text-sm font-medium text-gray-400 mb-1">Parent Category</label> <label for="category-parent" class="block text-sm font-medium text-gray-400 mb-1">Parent Category</label>
<select id="category-parent" <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"> 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 (Root Category)</option> <option value="">No Parent</option>
<!-- Options will be populated with JavaScript --> <!-- Options will be populated with JavaScript -->
</select> </select>
</div> </div>
@ -202,13 +208,13 @@
<div class="p-6"> <div class="p-6">
<p class="text-gray-300 mb-6"> <p class="text-gray-300 mb-6">
Are you sure you want to delete <span class="font-semibold text-white">{{ category.name if category else 'this category' }}</span>? Are you sure you want to delete <span class="font-semibold text-white">{{ category.name if category is not none else 'this category' }}</span>?
{% if category and (category.documents.count() > 0 or category.children.count() > 0) %} {% if category is not none and (category.documents.count() > 0 or category.children.count() > 0) %}
This category contains {{ category.documents.count() }} document(s) and {{ category.children.count() }} subcategory(ies). This category contains {{ category.documents.count() }} document(s) and {{ category.children.count() }} subcategory(ies).
{% endif %} {% endif %}
</p> </p>
{% if category and (category.documents.count() > 0 or category.children.count() > 0) %} {% if category is not none and (category.documents.count() > 0 or category.children.count() > 0) %}
<div class="mb-6"> <div class="mb-6">
<p class="text-white mb-2">What should happen to the contents?</p> <p class="text-white mb-2">What should happen to the contents?</p>
<div class="space-y-3"> <div class="space-y-3">
@ -266,15 +272,15 @@
fetch('/api/categories') fetch('/api/categories')
.then(response => response.json()) .then(response => response.json())
.then(categories => { .then(categories => {
// Find the root category
const rootCategory = categories.find(c => c.name === 'root');
// Add options recursively // Add options recursively
function addCategoryOptions(categories, depth = 0) { function addCategoryOptions(categories, depth = 0) {
categories.forEach(category => { categories.forEach(category => {
// Skip the category being edited to avoid circular references // Skip the category being edited to avoid circular references
{% if category %} // Also skip any root categories as they shouldn't be selectable as parents
if (category.id === {{ category.id }}) return; {% if category is not none %}
if (category.id === {{ category.id }} || category.is_root) return;
{% else %}
if (category.is_root) return;
{% endif %} {% endif %}
const option = document.createElement('option'); const option = document.createElement('option');
@ -287,23 +293,16 @@
// Select option logic: // Select option logic:
// 1. If we have a parent specified, select that parent // 1. If we have a parent specified, select that parent
// 2. If we're editing an existing category, select its current parent // 2. If we're editing an existing category, select its current parent
// 3. If creating a new category, select root by default
let selectThisOption = false; let selectThisOption = false;
{% if parent %} {% if parent is not none %}
if (category.id === {{ parent.id }}) { if (category.id === {{ parent.id }}) {
selectThisOption = true; selectThisOption = true;
} }
{% elif category and category.parent_id %} {% elif category is not none and category.parent_id is not none %}
if (category.id === {{ category.parent_id }}) { if (category.id === {{ category.parent_id }}) {
selectThisOption = true; selectThisOption = true;
} }
{% else %}
// If no parent specified and creating new category, default to root
if (category.is_root && !rootSelected) {
selectThisOption = true;
rootSelected = true;
}
{% endif %} {% endif %}
option.selected = selectThisOption; option.selected = selectThisOption;
@ -315,10 +314,7 @@
}); });
} }
// Initialize flag to track if root has been selected // Start from categories
let rootSelected = false;
// Start from root categories
addCategoryOptions(categories); addCategoryOptions(categories);
}) })
.catch(error => { .catch(error => {
@ -538,7 +534,8 @@
const queryParams = deleteOption === 'delete' ? '' : '?preserve_contents=true'; const queryParams = deleteOption === 'delete' ? '' : '?preserve_contents=true';
// Send delete request // Send delete request
fetch(`/api/category/{{ category.id if category else '' }}${queryParams}`, { {% if category is not none %}
fetch(`/api/category/{{ category.id }}${queryParams}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -568,7 +565,7 @@
// Redirect to home page or parent category // Redirect to home page or parent category
setTimeout(() => { setTimeout(() => {
{% if category and category.parent_id %} {% if category.parent_id %}
window.location.href = '/category/{{ category.parent_id }}'; window.location.href = '/category/{{ category.parent_id }}';
{% else %} {% else %}
window.location.href = '/'; window.location.href = '/';
@ -590,6 +587,7 @@
setTimeout(() => notification.remove(), 300); setTimeout(() => notification.remove(), 300);
}, 3000); }, 3000);
}); });
{% endif %}
}); });
} }
}); });

View file

@ -535,6 +535,12 @@
const listView = document.getElementById('list-view'); const listView = document.getElementById('list-view');
if (gridViewBtn && listViewBtn) { if (gridViewBtn && listViewBtn) {
// Initialize view (grid is default)
gridViewBtn.classList.add('active-view');
listViewBtn.classList.remove('active-view');
gridView.classList.remove('hidden');
listView.classList.add('hidden');
// Load saved preference // Load saved preference
const savedView = localStorage.getItem('categoryViewPreference'); const savedView = localStorage.getItem('categoryViewPreference');
if (savedView === 'list') { if (savedView === 'list') {