Add development documentation and enhance SPA navigation with category/item detail views

- Add DEVELOPMENT.md with complete LXC environment setup guide
- Implement dynamic category tree with item navigation in sidebar
- Add category detail view with items list via AJAX
- Add item edit form with delete functionality
- Enhance SPA routing to support query parameters for categories/items
- Update Bootstrap styling with icons and improved navigation
- Include SQLite database in repository for development
This commit is contained in:
2025-11-12 09:51:01 +01:00
parent deb27490c2
commit 9f9617ca45
11 changed files with 499 additions and 25 deletions

View File

@@ -13,6 +13,7 @@ use App\Router;
use App\Controllers\ItemController;
use App\Controllers\CategoryController;
use App\Database;
use App\Models\Category;
// Initialize Database (ensures tables exist)
Database::getInstance();
@@ -40,25 +41,58 @@ $router->addRoute('GET', '/print/{id:\d+}', [ItemController::class, 'printQR']);
// These routes return only the Twig block content, not the full layout.
$router->addRoute('GET', '/api/items', [ItemController::class, 'listItems']);
$router->addRoute('GET', '/api/items/{id:\d+}', [ItemController::class, 'getItem']);
$router->addRoute('GET', '/api/categories', [CategoryController::class, 'listCategories']);
$router->addRoute('GET', '/api/categories/list', [CategoryController::class, 'listCategoriesJson']);
$router->addRoute('GET', '/api/categories/{id}', [CategoryController::class, 'getCategory']);
$router->addRoute('GET', '/api/parts', [ItemController::class, 'renderAddForm']);
// Categories with optional filter
$router->addRoute('GET', '/api/categories', function() {
$categoryId = $_GET['category'] ?? null;
if ($categoryId) {
// Show category details with items
$controller = new CategoryController();
$controller->showCategory($categoryId);
} else {
// Show all categories
$controller = new CategoryController();
$controller->listCategories();
}
});
// Parts with optional item filter
$router->addRoute('GET', '/api/parts', function() {
$itemId = $_GET['item'] ?? null;
if ($itemId) {
// Show item details/edit form
$controller = new ItemController();
$controller->editItem($itemId);
} else {
// Show add form
$controller = new ItemController();
$controller->renderAddForm();
}
});
$router->addRoute('GET', '/api/tree', function() {
try {
$db = App\Database\Database::getInstance();
$categories = App\Models\Category::getAll($db);
$db = Database::getInstance();
$categories = Category::getAll($db);
function build_category_tree($categories, $parentId = null, &$visited = [], $depth = 0, &$nodeCount = 0) {
function build_category_tree($categories, $db, $parentId = null, $visited = [], $depth = 0, $nodeCount = 0) {
if ($depth > 5 || $nodeCount > 100) return [];
$tree = [];
foreach ($categories as $cat) {
if ($cat['parent_id'] == $parentId && !in_array($cat['id'], $visited)) {
$visited[] = $cat['id'];
// Get items for this category
$itemsStmt = $db->prepare('SELECT id, name FROM items WHERE category_id = :category_id ORDER BY name');
$itemsStmt->execute([':category_id' => $cat['id']]);
$items = $itemsStmt->fetchAll(PDO::FETCH_ASSOC);
$node = [
'id' => $cat['id'],
'name' => $cat['name'],
'children' => build_category_tree($categories, $cat['id'], $visited, $depth + 1, $nodeCount)
'items' => $items,
'children' => build_category_tree($categories, $db, $cat['id'], $visited, $depth + 1, $nodeCount)
];
$tree[] = $node;
$nodeCount++;
@@ -72,21 +106,39 @@ $router->addRoute('GET', '/api/tree', function() {
if ($depth > 5) return '';
$html = '';
foreach ($nodes as $node) {
$html .= '<li>';
$html .= '<span class="category" onclick="toggleCategory(this)">' . htmlspecialchars($node['name']) . '</span>';
$html .= '<li class="nav-item">';
$html .= '<a class="nav-link category-link" href="#" data-route="/categories?category=' . $node['id'] . '">';
$html .= '<i class="bi bi-folder"></i> ' . htmlspecialchars($node['name']);
$html .= '</a>';
// Add items as sub-items
if (!empty($node['items'])) {
$html .= '<ul class="nav flex-column nav-pills ms-2">';
foreach ($node['items'] as $item) {
$html .= '<li class="nav-item">';
$html .= '<a class="nav-link item-link" href="#" data-route="/parts?item=' . $item['id'] . '">';
$html .= '<i class="bi bi-file-earmark"></i> ' . htmlspecialchars($item['name']);
$html .= '</a>';
$html .= '</li>';
}
$html .= '</ul>';
}
// Add subcategories
if (!empty($node['children'])) {
$html .= '<ul style="display: none;">';
$html .= '<ul class="nav flex-column nav-pills ms-2">';
$html .= render_category_tree($node['children'], $depth + 1);
$html .= '</ul>';
}
$html .= '</li>';
}
return $html;
}
$nodeCount = 0;
$categoryTree = build_category_tree($categories, null, $visited = [], 0, $nodeCount);
$html = '<ul class="category-tree">' . render_category_tree($categoryTree) . '</ul>';
$categoryTree = build_category_tree($categories, $db, null, [], 0, $nodeCount);
$html = '<ul class="nav flex-column nav-pills">' . render_category_tree($categoryTree) . '</ul>';
if (strlen($html) > 10000) {
$html = '<ul class="nav flex-column nav-pills"><li class="nav-item"><a class="nav-link" href="#" data-route="/">Overview</a></li><li class="nav-item"><a class="nav-link" href="#" data-route="/categories">Categories</a></li><li class="nav-item"><a class="nav-link" href="#" data-route="/parts">Parts</a></li></ul>';
}

View File

@@ -40,12 +40,24 @@ document.addEventListener('DOMContentLoaded', function() {
apiPath = '/api/items';
pageTitle = 'Overview';
routePath = '/';
} else if (path === '/categories') {
apiPath = '/api/categories';
pageTitle = 'Categories';
} else if (path === '/parts') {
apiPath = '/api/parts';
pageTitle = 'Parts';
} else if (path.startsWith('/categories')) {
if (path.includes('?category=')) {
const categoryId = path.split('?category=')[1];
apiPath = '/api/categories?category=' + categoryId;
pageTitle = 'Category Details';
} else {
apiPath = '/api/categories';
pageTitle = 'Categories';
}
} else if (path.startsWith('/parts')) {
if (path.includes('?item=')) {
const itemId = path.split('?item=')[1];
apiPath = '/api/parts?item=' + itemId;
pageTitle = 'Part Details';
} else {
apiPath = '/api/parts';
pageTitle = 'Parts';
}
} else {
// Fallback
apiPath = '/api/items';
@@ -111,20 +123,23 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// Initial page load
const initialPath = window.location.pathname === '/' ? '/items' : window.location.pathname;
fetchContent(initialPath, false);
// Load category tree
// Load category tree first
fetch('/api/tree')
.then(response => response.text())
.then(html => {
document.querySelector('.sidebar').innerHTML = html;
const sidebarTree = document.getElementById('sidebar-tree');
if (sidebarTree) {
sidebarTree.innerHTML = html;
}
})
.catch(error => {
console.error('Error loading tree:', error);
});
// Initial page load
const initialPath = window.location.pathname === '/' ? '/items' : window.location.pathname;
fetchContent(initialPath, false);
// Page-specific scripts (e.g., form submissions)
function initPageSpecificScripts() {
// Overview page: filter form
@@ -172,6 +187,31 @@ document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.delete-category-btn').forEach(btn => {
btn.addEventListener('click', handleDeleteCategory);
});
// Item edit page: delete button
const deleteItemBtn = document.getElementById('deleteItemBtn');
if (deleteItemBtn) {
deleteItemBtn.addEventListener('click', function() {
const itemId = document.getElementById('edit_item_id').value;
if (confirm(window.translations.deletePartConfirm)) {
fetch('/api/items/' + itemId, {
method: 'DELETE'
})
.then(response => response.json())
.then(data => {
if (data.success) {
fetchContent('/', false);
} else {
alert(data.error || 'Error deleting item');
}
})
.catch(error => {
console.error('Error:', error);
alert('Error deleting item');
});
}
});
}
}
// Handler functions

View File

@@ -23,4 +23,36 @@
.item-link:hover {
text-decoration: underline;
}
/* Tree structure styling */
.category-link {
font-weight: 600;
color: #495057 !important;
background-color: #f8f9fa !important;
border: 1px solid #dee2e6;
border-radius: 4px;
margin: 2px 0;
}
.category-link:hover {
background-color: #e9ecef !important;
color: #212529 !important;
}
.item-link {
font-weight: 400;
color: #6c757d !important;
background-color: transparent !important;
border-left: 3px solid #007bff;
margin-left: 8px;
}
.item-link:hover {
background-color: #f8f9fa !important;
color: #007bff !important;
}
.bi {
margin-right: 5px;
}