flask-vim-docs/app/templates/category_edit.html
2025-04-17 12:05:22 +02:00

597 lines
No EOL
30 KiB
HTML

{% extends "base.html" %}
{% block title %}{% if category %}Edit Category{% else %}New Category{% endif %} - Vim Docs{% endblock %}
{% block header_title %}{% if category %}Edit Category{% else %}New Category{% endif %}{% endblock %}
{% block header_actions %}
<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
</button>
<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
</a>
{% 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">
<i class="mdi mdi-delete mr-2"></i> Delete
</button>
{% endif %}
{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto">
<div class="bg-gray-800 rounded-lg p-6 shadow-lg">
<div class="grid grid-cols-1 gap-6">
<div>
<label for="category-name" class="block text-sm font-medium text-gray-400 mb-1">Category Name *</label>
<input type="text" id="category-name" value="{% if category %}{{ category.name }}{% 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="category-icon" class="block text-sm font-medium text-gray-400 mb-1">Icon (Material Design Icon)</label>
<div class="relative">
<div class="flex items-center">
<div class="icon-preview w-10 h-10 flex items-center justify-center bg-gray-700 rounded-md mr-3">
<i id="icon-preview" class="mdi mdi-folder-outline text-xl"></i>
</div>
<input type="text" id="category-icon" value="{% if category %}{{ category.icon }}{% else %}mdi-folder-outline{% endif %}"
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">
<button type="button" id="icon-search-btn" class="ml-2 px-3 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-500">
<i class="mdi mdi-magnify"></i>
</button>
</div>
<!-- Icon Search Modal -->
<div id="icon-search-modal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-900 opacity-75"></div>
</div>
<div class="inline-block align-bottom bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
<div class="bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="mt-3 text-center sm:mt-0 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-white mb-4">
Select an Icon
</h3>
<div class="mt-2">
<!-- Search Input -->
<div class="mb-4">
<input type="text" id="icon-search-input" placeholder="Search icons..."
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>
<!-- Icons Grid -->
<div id="icons-grid" class="grid grid-cols-6 gap-2 max-h-96 overflow-y-auto">
<!-- Icons will be populated dynamically -->
</div>
</div>
</div>
</div>
<div class="bg-gray-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" id="close-icon-modal" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-600 shadow-sm px-4 py-2 bg-gray-700 text-white hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Close
</button>
</div>
</div>
</div>
</div>
</div>
<p class="text-sm text-gray-500 mt-1">Example: mdi-folder-outline, mdi-code-tags, mdi-book-open-page-variant</p>
</div>
<div>
<label for="category-description" class="block text-sm font-medium text-gray-400 mb-1">Description</label>
<textarea id="category-description" rows="3"
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">{% if category %}{{ category.description }}{% endif %}</textarea>
</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 (Root Category)</option>
<!-- Options will be populated with JavaScript -->
</select>
</div>
</div>
</div>
{% if category %}
<!-- Category Contents Section -->
<div class="mt-8">
<h3 class="text-lg font-medium text-white mb-4">Category Contents</h3>
<!-- Documents -->
<div class="mb-6">
<div class="flex items-center justify-between mb-2">
<h4 class="text-md font-medium text-gray-300">Documents</h4>
<a href="{{ url_for('main.new_document') }}?category={{ category.id }}" class="text-primary hover:text-primary-light text-sm flex items-center">
<i class="mdi mdi-file-plus-outline mr-1"></i> Add Document
</a>
</div>
<div class="bg-gray-800 rounded-lg shadow">
{% if category.documents.count() > 0 %}
<ul class="divide-y divide-gray-700">
{% for doc in category.documents %}
<li class="p-3 hover:bg-gray-700 transition-colors">
<div class="flex items-center justify-between">
<a href="{{ url_for('main.view_document', doc_id=doc.id) }}" class="flex items-center text-gray-300 hover:text-primary">
<i class="mdi mdi-file-document-outline mr-2"></i>
<span>{{ doc.title }}</span>
</a>
<div class="flex items-center space-x-2">
<a href="{{ url_for('main.edit_document', doc_id=doc.id) }}" class="text-gray-400 hover:text-primary" title="Edit">
<i class="mdi mdi-pencil-outline"></i>
</a>
<a href="{{ url_for('main.export_document', doc_id=doc.id) }}" class="text-gray-400 hover:text-primary" title="Export">
<i class="mdi mdi-download-outline"></i>
</a>
</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="p-4 text-center text-gray-500">
No documents in this category
</div>
{% endif %}
</div>
</div>
<!-- Subcategories -->
<div>
<div class="flex items-center justify-between mb-2">
<h4 class="text-md font-medium text-gray-300">Subcategories</h4>
<a href="{{ url_for('main.new_category') }}?parent_id={{ category.id }}" class="text-primary hover:text-primary-light text-sm flex items-center">
<i class="mdi mdi-folder-plus-outline mr-1"></i> Add Subcategory
</a>
</div>
<div class="bg-gray-800 rounded-lg shadow">
{% if category.children.count() > 0 %}
<ul class="divide-y divide-gray-700">
{% for subcategory in category.children %}
<li class="p-3 hover:bg-gray-700 transition-colors">
<div class="flex items-center justify-between">
<a href="{{ url_for('main.view_category', category_id=subcategory.id) }}" class="flex items-center text-gray-300 hover:text-primary">
<i class="mdi {{ subcategory.icon }} mr-2"></i>
<span>{{ subcategory.name }}</span>
</a>
<div class="flex items-center space-x-2">
<a href="{{ url_for('main.edit_category', category_id=subcategory.id) }}" class="text-gray-400 hover:text-primary" title="Edit">
<i class="mdi mdi-pencil-outline"></i>
</a>
<a href="{{ url_for('main.new_document') }}?category={{ subcategory.id }}" class="text-gray-400 hover:text-primary" title="Add Document">
<i class="mdi mdi-file-plus-outline"></i>
</a>
</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="p-4 text-center text-gray-500">
No subcategories
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
</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>Category saved successfully!</span>
</div>
<!-- Delete Category Modal -->
<div id="delete-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 class="text-lg font-medium text-white">Delete Category</h3>
<button id="close-delete-modal" class="text-gray-400 hover:text-white">
<i class="mdi mdi-close text-lg"></i>
</button>
</div>
<div class="p-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>?
{% if category and (category.documents.count() > 0 or category.children.count() > 0) %}
This category contains {{ category.documents.count() }} document(s) and {{ category.children.count() }} subcategory(ies).
{% endif %}
</p>
{% if category and (category.documents.count() > 0 or category.children.count() > 0) %}
<div class="mb-6">
<p class="text-white mb-2">What should happen to the contents?</p>
<div class="space-y-3">
<label class="flex items-start">
<input type="radio" name="delete-option" value="move" class="mt-1 mr-2" checked>
<div>
<span class="text-gray-300">Move contents to parent/root category</span>
<p class="text-xs text-gray-500">Documents and subcategories will be preserved and moved to the parent category</p>
</div>
</label>
<label class="flex items-start">
<input type="radio" name="delete-option" value="delete" class="mt-1 mr-2">
<div>
<span class="text-gray-300">Delete all contents</span>
<p class="text-xs text-gray-500">All documents and subcategories will be permanently deleted</p>
</div>
</label>
</div>
</div>
{% endif %}
<div class="flex justify-end space-x-3">
<button id="cancel-delete-btn" class="px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-600 transition-colors">
Cancel
</button>
<button id="confirm-delete-btn" class="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-500 transition-colors">
Delete
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const saveButton = document.getElementById('save-button');
const nameInput = document.getElementById('category-name');
const iconInput = document.getElementById('category-icon');
const descriptionInput = document.getElementById('category-description');
const parentSelect = document.getElementById('category-parent');
const notification = document.getElementById('save-notification');
// Load all categories for parent selection
loadCategoryOptions();
// Set default parent if specified
{% if parent %}
const parentId = {{ parent.id }};
{% endif %}
function loadCategoryOptions() {
fetch('/api/categories')
.then(response => response.json())
.then(categories => {
// Find the root category
const rootCategory = categories.find(c => c.name === 'root');
// Add options recursively
function addCategoryOptions(categories, depth = 0) {
categories.forEach(category => {
// Skip the category being edited to avoid circular references
{% if category %}
if (category.id === {{ category.id }}) return;
{% endif %}
const option = document.createElement('option');
option.value = category.id;
// Add indentation using spaces for hierarchical display
const indent = '\u00A0\u00A0\u00A0\u00A0'.repeat(depth);
option.textContent = indent + (depth > 0 ? '└─ ' : '') + category.name;
// Select option logic:
// 1. If we have a parent specified, select that 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;
{% if parent %}
if (category.id === {{ parent.id }}) {
selectThisOption = true;
}
{% elif category and category.parent_id %}
if (category.id === {{ category.parent_id }}) {
selectThisOption = true;
}
{% else %}
// If no parent specified and creating new category, default to root
if (category.is_root && !rootSelected) {
selectThisOption = true;
rootSelected = true;
}
{% endif %}
option.selected = selectThisOption;
parentSelect.appendChild(option);
if (category.children && category.children.length > 0) {
addCategoryOptions(category.children, depth + 1);
}
});
}
// Initialize flag to track if root has been selected
let rootSelected = false;
// Start from root categories
addCategoryOptions(categories);
})
.catch(error => {
console.error('Error loading categories:', error);
});
}
saveButton.addEventListener('click', function() {
const categoryData = {
name: nameInput.value.trim(),
icon: iconInput.value.trim() || 'mdi-folder-outline',
description: descriptionInput.value.trim(),
parent_id: parentSelect.value || null
};
// Validation
if (!categoryData.name) {
alert('Please enter a category name');
nameInput.focus();
return;
}
{% if category %}
categoryData.id = {{ category.id }};
{% endif %}
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);
// 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);
// Redirect to category view
setTimeout(() => {
window.location.href = `/category/${data.id}`;
}, 1000);
})
.catch(error => {
console.error('Error saving category:', error);
alert('Error saving category: ' + error.message);
});
});
// Icon Preview Functionality
const iconPreview = document.getElementById('icon-preview');
// Update icon preview when input changes
function updateIconPreview() {
const iconClass = iconInput.value.trim();
// Remove all mdi classes
iconPreview.className = '';
// Add the mdi base class and the selected icon class
iconPreview.classList.add('mdi');
// If the value doesn't start with mdi-, add it
if (iconClass.startsWith('mdi-')) {
iconPreview.classList.add(iconClass);
} else if (iconClass) {
iconPreview.classList.add('mdi-' + iconClass.replace('mdi-', ''));
} else {
iconPreview.classList.add('mdi-folder-outline');
}
}
// Initial preview
updateIconPreview();
// Update preview on input change
iconInput.addEventListener('input', updateIconPreview);
// Icon Search Modal
const iconSearchBtn = document.getElementById('icon-search-btn');
const iconSearchModal = document.getElementById('icon-search-modal');
const closeIconModal = document.getElementById('close-icon-modal');
const iconSearchInput = document.getElementById('icon-search-input');
const iconsGrid = document.getElementById('icons-grid');
// Common Material Design Icons
const commonIcons = [
'mdi-folder-outline', 'mdi-folder', 'mdi-folder-open', 'mdi-folder-plus',
'mdi-file-document-outline', 'mdi-file-document', 'mdi-file',
'mdi-note-outline', 'mdi-note', 'mdi-notebook', 'mdi-notebook-outline',
'mdi-code-tags', 'mdi-code-braces', 'mdi-console', 'mdi-database',
'mdi-server', 'mdi-desktop-tower', 'mdi-monitor', 'mdi-laptop',
'mdi-home', 'mdi-account', 'mdi-cog', 'mdi-star', 'mdi-heart',
'mdi-book', 'mdi-book-open', 'mdi-bookshelf', 'mdi-bookmark',
'mdi-alert', 'mdi-information', 'mdi-help-circle', 'mdi-check-circle',
'mdi-tools', 'mdi-wrench', 'mdi-hammer', 'mdi-screwdriver',
'mdi-shield', 'mdi-lock', 'mdi-key', 'mdi-wifi', 'mdi-web',
'mdi-link', 'mdi-github', 'mdi-git', 'mdi-docker', 'mdi-language-python',
'mdi-language-javascript', 'mdi-language-html5', 'mdi-language-css3',
'mdi-chart-bar', 'mdi-chart-line', 'mdi-chart-pie', 'mdi-chart-bubble',
'mdi-movie', 'mdi-music', 'mdi-image', 'mdi-camera', 'mdi-video',
'mdi-atom', 'mdi-microscope', 'mdi-flask', 'mdi-test-tube',
'mdi-robot', 'mdi-brain', 'mdi-bug', 'mdi-rocket', 'mdi-satellite',
'mdi-car', 'mdi-airplane', 'mdi-train', 'mdi-bike', 'mdi-walk',
'mdi-food', 'mdi-coffee', 'mdi-beer', 'mdi-pizza', 'mdi-cake',
'mdi-currency-usd', 'mdi-cash', 'mdi-credit-card', 'mdi-bank',
'mdi-message', 'mdi-email', 'mdi-chat', 'mdi-forum', 'mdi-comment',
'mdi-calendar', 'mdi-clock', 'mdi-alarm', 'mdi-timer', 'mdi-watch',
'mdi-weather-sunny', 'mdi-weather-night', 'mdi-weather-rainy', 'mdi-weather-snowy',
'mdi-map', 'mdi-map-marker', 'mdi-compass', 'mdi-earth', 'mdi-directions',
'mdi-shopping', 'mdi-cart', 'mdi-store', 'mdi-tag', 'mdi-sale',
'mdi-palette', 'mdi-brush', 'mdi-format-paint', 'mdi-pencil', 'mdi-pen',
'mdi-printer', 'mdi-scanner', 'mdi-fax', 'mdi-file-pdf', 'mdi-file-excel',
'mdi-file-word', 'mdi-file-powerpoint', 'mdi-file-image', 'mdi-file-video',
'mdi-file-music', 'mdi-file-xml', 'mdi-file-code', 'mdi-zip-box'
];
// Populate the icons grid
function populateIconsGrid(searchTerm = '') {
iconsGrid.innerHTML = '';
const filteredIcons = searchTerm
? commonIcons.filter(icon => icon.toLowerCase().includes(searchTerm.toLowerCase()))
: commonIcons;
filteredIcons.forEach(icon => {
const iconDiv = document.createElement('div');
iconDiv.className = 'cursor-pointer p-2 bg-gray-700 rounded-md flex flex-col items-center hover:bg-gray-600';
const iconEl = document.createElement('i');
iconEl.className = 'mdi ' + icon + ' text-2xl mb-1';
const iconName = document.createElement('div');
iconName.className = 'text-xs text-gray-400 truncate w-full text-center';
iconName.textContent = icon.replace('mdi-', '');
iconDiv.appendChild(iconEl);
iconDiv.appendChild(iconName);
// Select icon when clicked
iconDiv.addEventListener('click', function() {
iconInput.value = icon;
updateIconPreview();
iconSearchModal.classList.add('hidden');
});
iconsGrid.appendChild(iconDiv);
});
}
// Open modal
iconSearchBtn.addEventListener('click', function() {
populateIconsGrid();
iconSearchModal.classList.remove('hidden');
iconSearchInput.focus();
});
// Close modal
closeIconModal.addEventListener('click', function() {
iconSearchModal.classList.add('hidden');
});
// Click outside to close
iconSearchModal.addEventListener('click', function(e) {
if (e.target === iconSearchModal) {
iconSearchModal.classList.add('hidden');
}
});
// Search functionality
iconSearchInput.addEventListener('input', function() {
populateIconsGrid(this.value);
});
// Delete Category Functionality
const deleteBtn = document.getElementById('delete-category-btn');
const deleteModal = document.getElementById('delete-category-modal');
const closeDeleteModalBtn = document.getElementById('close-delete-modal');
const cancelDeleteBtn = document.getElementById('cancel-delete-btn');
const confirmDeleteBtn = document.getElementById('confirm-delete-btn');
if (deleteBtn) {
deleteBtn.addEventListener('click', function() {
deleteModal.classList.remove('hidden');
});
}
if (closeDeleteModalBtn) {
closeDeleteModalBtn.addEventListener('click', function() {
deleteModal.classList.add('hidden');
});
}
if (cancelDeleteBtn) {
cancelDeleteBtn.addEventListener('click', function() {
deleteModal.classList.add('hidden');
});
}
if (confirmDeleteBtn) {
confirmDeleteBtn.addEventListener('click', function() {
// Get selected option
const deleteOption = document.querySelector('input[name="delete-option"]:checked')?.value || 'move';
const queryParams = deleteOption === 'delete' ? '' : '?preserve_contents=true';
// Send delete request
fetch(`/api/category/{{ category.id if category else '' }}${queryParams}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => {
if (!response.ok) {
throw new Error('Server responded with an error: ' + response.status);
}
return response.json();
})
.then(data => {
console.log("Category deleted successfully:", data);
// Show save notification
const notification = document.createElement('div');
notification.className = 'fixed bottom-4 right-4 bg-primary/90 text-white px-4 py-2 rounded-md shadow-lg transform translate-y-0 opacity-100 transition-all duration-300';
notification.innerHTML = '<i class="mdi mdi-check-circle mr-2"></i> Category deleted successfully';
document.body.appendChild(notification);
// Hide notification after 3 seconds
setTimeout(() => {
notification.classList.add('translate-y-16', 'opacity-0');
setTimeout(() => notification.remove(), 300);
}, 3000);
// Redirect to home page or parent category
setTimeout(() => {
{% if category and category.parent_id %}
window.location.href = '/category/{{ category.parent_id }}';
{% else %}
window.location.href = '/';
{% endif %}
}, 1000);
})
.catch(error => {
console.error('Error deleting category:', error);
// Show error notification
const notification = document.createElement('div');
notification.className = 'fixed bottom-4 right-4 bg-red-600/90 text-white px-4 py-2 rounded-md shadow-lg transform translate-y-0 opacity-100 transition-all duration-300';
notification.innerHTML = '<i class="mdi mdi-alert-circle mr-2"></i> Error deleting category';
document.body.appendChild(notification);
// Hide notification after 3 seconds
setTimeout(() => {
notification.classList.add('translate-y-16', 'opacity-0');
setTimeout(() => notification.remove(), 300);
}, 3000);
});
});
}
});
</script>
{% endblock %}