Restructured pages #6

Merged
adebaumann merged 1 commits from improvement/redesign into master 2026-01-06 11:55:55 +00:00
6 changed files with 224 additions and 65 deletions
Showing only changes of commit ebf3b9d00a - Show all commits

View File

@@ -214,10 +214,10 @@ labhelper/
│ │ ├── add_things.html # Form to add multiple things
│ │ ├── box_detail.html # Box contents view
│ │ ├── box_management.html # Box/BoxType CRUD management
│ │ ├── boxes_list.html # Boxes list page with tabular view
│ │ ├── edit_thing.html # Edit thing page (name, description, picture, tags, files, links)
│ │ ├── index.html # Home page with boxes and tags
│ │ ├── index.html # Home page with search and tags
│ │ ├── resources_list.html # List all links and files from things
│ │ ├── search.html # Search page with AJAX
│ │ └── thing_detail.html # Read-only thing details view
│ ├── templatetags/
│ │ └── dict_extras.py # Custom template filters: get_item, render_markdown, truncate_markdown
@@ -378,7 +378,8 @@ The project uses a base template system at `labhelper/templates/base.html`. All
| View Function | URL Pattern | Name | Description |
|---------------|-------------|------|-------------|
| `index` | `/` | `index` | Home page with boxes grid and tags overview |
| `index` | `/` | `index` | Home page with search and tags overview |
| `boxes_list` | `/search/` | `search`, `boxes_list` | Boxes list page with tabular view |
| `box_management` | `/box-management/` | `box_management` | Manage boxes and box types |
| `add_box_type` | `/box-type/add/` | `add_box_type` | Add new box type |
| `edit_box_type` | `/box-type/<int:type_id>/edit/` | `edit_box_type` | Edit box type |
@@ -390,7 +391,6 @@ The project uses a base template system at `labhelper/templates/base.html`. All
| `thing_detail` | `/thing/<int:thing_id>/` | `thing_detail` | Read-only view of thing details |
| `edit_thing` | `/thing/<int:thing_id>/edit/` | `edit_thing` | Edit thing (name, description, picture, tags, files, links, move) |
| `add_things` | `/box/<str:box_id>/add/` | `add_things` | Add multiple things to a box |
| `search` | `/search/` | `search` | Search page |
| `search_api` | `/search/api/` | `search_api` | AJAX search endpoint |
| `resources_list` | `/resources/` | `resources_list` | List all links and files from things (sorted by thing name) |
| `LoginView` | `/login/` | `login` | Django auth login |

View File

@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}Boxes - LabHelper{% endblock %}
{% block page_header %}
<div class="page-header">
<h1><i class="fas fa-boxes"></i> Boxes</h1>
<p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> / Boxes
</p>
</div>
{% endblock %}
{% block content %}
{% if boxes %}
<div class="section" style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Box ID</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Type</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Dimensions (mm)</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Contents</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Item Count</th>
</tr>
</thead>
<tbody>
{% for box in boxes %}
<tr style="border-bottom: 1px solid #e0e0e0; transition: background 0.2s;" class="box-row">
<td style="padding: 15px 20px; font-weight: 700; color: #667eea;">
<a href="{% url 'box_detail' box.id %}" style="text-decoration: none; color: inherit;">Box {{ box.id }}</a>
</td>
<td style="padding: 15px 20px;">{{ box.box_type.name }}</td>
<td style="padding: 15px 20px;">{{ box.box_type.width }} x {{ box.box_type.height }} x {{ box.box_type.length }}</td>
<td style="padding: 15px 20px; color: #555;">
{% if box.things.all %}
<div style="display: flex; flex-wrap: wrap; gap: 5px;">
{% for thing in box.things.all %}
<a href="{% url 'thing_detail' thing.id %}" style="display: inline-block; padding: 4px 10px; background: #f0f0f0; border-radius: 12px; text-decoration: none; color: #333; font-size: 13px; font-weight: 500;">{{ thing.name }}</a>
{% endfor %}
</div>
{% else %}
<span style="color: #999; font-style: italic;">Empty</span>
{% endif %}
</td>
<td style="padding: 15px 20px; font-weight: 600;">{{ box.things.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="section" style="text-align: center; padding: 60px 30px;">
<i class="fas fa-box-open" style="font-size: 64px; color: #ddd; margin-bottom: 20px; display: block;"></i>
<h3 style="color: #888; font-size: 20px;">No boxes found</h3>
<p style="color: #999; margin-top: 10px;">Create your first box to get started.</p>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
$('.box-row').hover(
function() {
$(this).css('background', '#f8f9fa');
},
function() {
$(this).css('background', 'white');
}
);
});
</script>
{% endblock %}

View File

@@ -11,34 +11,37 @@
{% block content %}
<div class="section">
<h2><i class="fas fa-box"></i> Boxes</h2>
{% if boxes %}
<div class="box-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px;">
{% for box in boxes %}
<div class="box-card" style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); padding: 20px; border-radius: 12px; border: 1px solid #e0e0e0; transition: all 0.3s ease; cursor: pointer;">
<a href="{% url 'box_detail' box.id %}" style="text-decoration: none; color: #333; display: block;">
<div class="box-id" style="font-size: 20px; font-weight: 700; color: #667eea; margin-bottom: 8px;">
<i class="fas fa-cube"></i> Box {{ box.id }}
</div>
<div class="box-type" style="font-size: 15px; color: #555; margin-bottom: 5px;">
{{ box.box_type.name }}
</div>
<div class="box-type" style="font-size: 13px; color: #777; margin-bottom: 5px;">
<i class="fas fa-ruler-combined"></i> {{ box.box_type.width }} x {{ box.box_type.height }} x {{ box.box_type.length }} mm
</div>
<div class="box-type" style="font-size: 13px; color: #777;">
<i class="fas fa-layer-group"></i> {{ box.things.count }} item{{ box.things.count|pluralize }}
</div>
</a>
</div>
{% endfor %}
</div>
{% else %}
<p style="text-align: center; color: #888; font-size: 16px; padding: 40px;">
<i class="fas fa-box-open" style="font-size: 48px; margin-bottom: 15px; display: block;"></i>
No boxes found.
<input type="text"
id="search-input"
placeholder="Search for things..."
style="width: 100%; padding: 16px 20px; font-size: 18px; border: 2px solid #e0e0e0; border-radius: 12px; box-sizing: border-box; transition: all 0.3s;"
{% if request.GET.q %}value="{{ request.GET.q }}"{% endif %}>
<p style="color: #888; font-size: 14px; margin-top: 10px;">
<i class="fas fa-info-circle"></i> Type at least 2 characters to search
</p>
{% endif %}
</div>
<div id="results-container" style="display: none;">
<div class="section" style="overflow-x: auto; padding: 0;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Name</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Tags</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Box</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Description</th>
</tr>
</thead>
<tbody id="results-body">
</tbody>
</table>
</div>
</div>
<div id="no-results" class="section" style="text-align: center; padding: 60px 30px; display: none;">
<i class="fas fa-search-minus" style="font-size: 64px; color: #ddd; margin-bottom: 20px; display: block;"></i>
<h3 style="color: #888; font-size: 20px;">No results found</h3>
<p style="color: #999; margin-top: 10px;">Try different keywords or browse the full inventory.</p>
</div>
<div class="section">
@@ -57,7 +60,7 @@
<div class="facet-tags" style="padding: 15px 20px; display: none;">
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
{% for tag, count in tags_with_counts %}
<a href="/search/?q={{ facet.name }}:{{ tag.name }}" style="display: inline-block; padding: 6px 12px; background: {{ facet.color }}20; color: {{ facet.color }}; border: 1px solid {{ facet.color }}; border-radius: 15px; text-decoration: none; font-size: 14px; font-weight: 600; transition: all 0.2s;">
<a href="/?q={{ facet.name }}:{{ tag.name }}" style="display: inline-block; padding: 6px 12px; background: {{ facet.color }}20; color: {{ facet.color }}; border: 1px solid {{ facet.color }}; border-radius: 15px; text-decoration: none; font-size: 14px; font-weight: 600; transition: all 0.2s;">
{{ tag.name }}
<span style="background: {{ facet.color }}; color: white; padding: 1px 8px; border-radius: 10px; margin-left: 6px; font-size: 12px;">{{ count }}</span>
</a>
@@ -79,6 +82,97 @@
{% block extra_js %}
<script>
$(document).ready(function() {
const searchInput = document.getElementById('search-input');
const resultsContainer = document.getElementById('results-container');
const resultsBody = document.getElementById('results-body');
const noResults = document.getElementById('no-results');
let searchTimeout = null;
function performSearch(query) {
if (query.length < 2) {
resultsContainer.style.display = 'none';
noResults.style.display = 'none';
return;
}
searchInput.style.borderColor = '#667eea';
searchInput.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
searchTimeout = setTimeout(function() {
fetch('/search/api/?q=' + encodeURIComponent(query))
.then(response => response.json())
.then(data => {
resultsBody.innerHTML = '';
if (data.results.length === 0) {
resultsContainer.style.display = 'none';
noResults.style.display = 'block';
return;
}
noResults.style.display = 'none';
resultsContainer.style.display = 'block';
data.results.forEach(function(thing) {
const row = document.createElement('tr');
row.style.borderBottom = '1px solid #e0e0e0';
row.style.transition = 'background 0.2s';
let tagsHtml = thing.tags.length > 0
? thing.tags.map(tag =>
'<span style="display: inline-block; padding: 3px 8px; margin: 2px; border-radius: 12px; font-size: 11px; background: ' + escapeHtml(tag.color) + '20; color: ' + escapeHtml(tag.color) + '; border: 1px solid ' + escapeHtml(tag.color) + '40;">' + escapeHtml(tag.name) + '</span>'
).join('')
: '<span style="color: #999; font-style: italic; font-size: 13px;">-</span>';
row.innerHTML =
'<td style="padding: 15px 20px;"><a href="/thing/' + thing.id + '/">' + escapeHtml(thing.name) + '</a></td>' +
'<td style="padding: 15px 20px;">' + tagsHtml + '</td>' +
'<td style="padding: 15px 20px;"><a href="/box/' + escapeHtml(thing.box) + '/">' + escapeHtml(thing.box) + '</a></td>' +
'<td style="padding: 15px 20px; color: #777;" class="description">' + escapeHtml(thing.description) + '</td>';
row.addEventListener('mouseenter', function() {
this.style.background = '#f8f9fa';
});
row.addEventListener('mouseleave', function() {
this.style.background = 'white';
});
resultsBody.appendChild(row);
});
});
}, 200);
}
searchInput.addEventListener('input', function() {
const query = this.value.trim();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
performSearch(query);
});
searchInput.addEventListener('blur', function() {
searchInput.style.borderColor = '#e0e0e0';
searchInput.style.boxShadow = 'none';
});
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
const urlParams = new URLSearchParams(window.location.search);
const initialQuery = urlParams.get('q');
if (initialQuery) {
searchInput.value = initialQuery;
performSearch(initialQuery.trim());
}
$('.facet-header').click(function() {
const $content = $(this).next('.facet-tags');
const $icon = $(this).find('.facet-toggle');
@@ -99,17 +193,6 @@ $(document).ready(function() {
$(this).css('transform', 'scale(1)');
}
);
$('.box-card').hover(
function() {
$(this).css('transform', 'translateY(-5px)');
$(this).css('box-shadow', '0 12px 24px rgba(102, 126, 234, 0.2)');
},
function() {
$(this).css('transform', 'translateY(0)');
$(this).css('box-shadow', 'none');
}
);
});
</script>
{% endblock %}

View File

@@ -271,6 +271,11 @@ class IndexViewTests(AuthTestCase):
response = self.client.get('/')
self.assertContains(response, '/admin/')
def test_index_contains_search_input(self):
"""Index page should contain search input."""
response = self.client.get('/')
self.assertContains(response, 'id="search-input"')
def test_index_requires_login(self):
"""Index page should redirect to login if not authenticated."""
self.client.logout()
@@ -486,26 +491,21 @@ class ThingDetailViewTests(AuthTestCase):
self.assertContains(response, f'/thing/{self.thing.id}/edit/')
class SearchViewTests(AuthTestCase):
"""Tests for search view."""
class BoxesListViewTests(AuthTestCase):
"""Tests for boxes list view."""
def test_search_returns_200(self):
"""Search page should return 200 status."""
def test_boxes_list_returns_200(self):
"""Boxes list page should return 200 status."""
response = self.client.get('/search/')
self.assertEqual(response.status_code, 200)
def test_search_contains_search_input(self):
"""Search page should contain search input field."""
def test_boxes_list_contains_boxes_header(self):
"""Boxes list page should contain boxes header."""
response = self.client.get('/search/')
self.assertContains(response, 'id="search-input"')
self.assertContains(response, 'Boxes')
def test_search_contains_results_container(self):
"""Search page should contain results table."""
response = self.client.get('/search/')
self.assertContains(response, 'id="results-container"')
def test_search_requires_login(self):
"""Search page should redirect to login if not authenticated."""
def test_boxes_list_requires_login(self):
"""Boxes list page should redirect to login if not authenticated."""
self.client.logout()
response = self.client.get('/search/')
self.assertRedirects(response, '/login/?next=/search/')

View File

@@ -32,8 +32,7 @@ def _strip_markdown(text, max_length=100):
@login_required
def index(request):
"""Home page with boxes and tags."""
boxes = Box.objects.select_related('box_type').all().order_by('id')
"""Home page with search and tags."""
facets = Facet.objects.all().prefetch_related('tags')
facet_tag_counts = {}
@@ -46,7 +45,6 @@ def index(request):
facet_tag_counts[facet].append((tag, count))
return render(request, 'boxes/index.html', {
'boxes': boxes,
'facets': facets,
'facet_tag_counts': facet_tag_counts,
})
@@ -192,9 +190,12 @@ def edit_thing(request, thing_id):
@login_required
def search(request):
"""Search page for things."""
return render(request, 'boxes/search.html')
def boxes_list(request):
"""Boxes list page showing all boxes with contents."""
boxes = Box.objects.select_related('box_type').prefetch_related('things').all().order_by('id')
return render(request, 'boxes/boxes_list.html', {
'boxes': boxes,
})
@login_required

View File

@@ -26,6 +26,7 @@ from boxes.views import (
add_things,
box_detail,
box_management,
boxes_list,
delete_box,
delete_box_type,
edit_box,
@@ -34,7 +35,6 @@ from boxes.views import (
fixme,
index,
resources_list,
search,
search_api,
thing_detail,
)
@@ -54,7 +54,8 @@ urlpatterns = [
path('thing/<int:thing_id>/', thing_detail, name='thing_detail'),
path('thing/<int:thing_id>/edit/', edit_thing, name='edit_thing'),
path('box/<str:box_id>/add/', add_things, name='add_things'),
path('search/', search, name='search'),
path('boxes/', boxes_list, name='boxes_list'),
path('search/', boxes_list, name='search'),
path('search/api/', search_api, name='search_api'),
path('resources/', resources_list, name='resources_list'),
path('fixme/', fixme, name='fixme'),