Add mineral search and permalink pages
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- Search bar toggle in header (magnifying glass icon)
- /daily-stone/search/?q= endpoint with results list
- /daily-stone/mineral/<id>/ permalink for each mineral
- Mineral count shown in footer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-30 22:44:13 +03:00
parent aa64d6949a
commit 7220af6a60
4 changed files with 365 additions and 14 deletions

View File

@@ -0,0 +1,220 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Search — Daily Stone</title>
<style>
:root {
--stone-color: #6b7280;
--stone-muted: #f0f0ec;
--stone-text: #3d4250;
--bg: #faf9f6;
--text: #2c2c2c;
--text-secondary: #5a5a5a;
--border: #d0d0d0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Georgia', 'Times New Roman', serif;
background: var(--bg);
color: var(--text);
line-height: 1.7;
min-height: 100vh;
}
.accent-bar {
height: 6px;
background: linear-gradient(90deg, #4a5568, #6b7280, #4a5568);
}
.container {
max-width: 640px;
margin: 0 auto;
padding: 0 1.25rem 3rem;
}
.page-header {
text-align: center;
padding: 2rem 0 1rem;
border-bottom: 1px solid var(--border);
margin-bottom: 1.5rem;
}
.page-header .label {
font-size: 0.75rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.page-header h1 {
font-size: 2rem;
font-weight: normal;
color: var(--stone-text);
}
.search-form {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.search-form input[type="text"] {
flex: 1;
padding: 0.6rem 0.75rem;
font-family: 'Georgia', serif;
font-size: 0.95rem;
border: 1px solid var(--border);
border-radius: 3px;
background: #fff;
color: var(--text);
outline: none;
}
.search-form input[type="text"]:focus {
border-color: var(--stone-color);
}
.search-form button {
padding: 0.6rem 1rem;
font-family: 'Georgia', serif;
font-size: 0.9rem;
color: var(--stone-text);
background: var(--stone-muted);
border: 1px solid var(--border);
border-radius: 3px;
cursor: pointer;
}
.search-form button:hover {
background: #e5e5e0;
}
.result-count {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 1rem;
}
.results-list {
list-style: none;
}
.results-list li {
border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
}
.results-list a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0.25rem;
text-decoration: none;
color: var(--text);
transition: background 0.15s;
}
.results-list a:hover {
background: var(--stone-muted);
}
.result-swatch {
width: 28px;
height: 28px;
border-radius: 50%;
flex-shrink: 0;
border: 1px solid var(--border);
}
.result-name {
font-size: 1.05rem;
}
.result-formula {
font-size: 0.85rem;
color: var(--text-secondary);
margin-left: 0.5rem;
}
.no-results {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.page-footer {
text-align: center;
padding-top: 2rem;
border-top: 1px solid var(--border);
margin-top: 3rem;
}
.page-footer a {
color: var(--stone-text);
text-decoration: none;
font-size: 0.85rem;
}
.page-footer a:hover { text-decoration: underline; }
.page-footer .home-link {
display: inline-block;
margin-top: 1rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.page-footer .mineral-count {
display: block;
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
</style>
</head>
<body>
<div class="accent-bar"></div>
<div class="container">
<header class="page-header">
<div class="label">Daily Stone</div>
<h1>Search Minerals</h1>
</header>
<form class="search-form" action="{% url 'dailystone:search' %}" method="get">
<input type="text" name="q" value="{{ query }}" placeholder="Mineral name..." autofocus>
<button type="submit">Search</button>
</form>
{% if results %}
<div class="result-count">{{ results|length }} result{{ results|length|pluralize }} for "{{ query }}"</div>
<ul class="results-list">
{% for m in results %}
<li>
<a href="{% url 'dailystone:mineral_detail' pk=m.pk %}">
<span class="result-swatch" style="background: {{ m.color_hex }};"></span>
<span class="result-name">{{ m.name }}</span>
{% if m.formula %}<span class="result-formula">{{ m.formula }}</span>{% endif %}
</a>
</li>
{% endfor %}
</ul>
{% else %}
<div class="no-results">
<p>No minerals found for "{{ query }}"</p>
</div>
{% endif %}
<footer class="page-footer">
<a href="{% url 'dailystone:daily_stone' %}">&larr; Today's stone</a>
<span class="mineral-count">{{ total_minerals }} minerals in collection</span>
<br>
<a href="/" class="home-link">k-boris.tech</a>
</footer>
</div>
</body>
</html>

View File

@@ -60,14 +60,90 @@
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.random-btn { .header-actions {
position: absolute; position: absolute;
top: 1.5rem; top: 1.5rem;
right: 0; right: 0;
display: flex;
gap: 0.4rem;
align-items: center;
}
.search-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
font-family: 'Georgia', serif;
color: var(--stone-text);
background: var(--stone-muted);
border: 1px solid var(--border);
border-radius: 3px;
text-decoration: none;
cursor: pointer;
transition: background 0.2s;
}
.search-toggle:hover {
background: color-mix(in srgb, var(--stone-color) 20%, #f5f5f0);
}
.search-toggle svg {
width: 14px;
height: 14px;
}
.search-bar {
display: none;
margin: -0.5rem 0 1.5rem;
}
.search-bar.open {
display: block;
}
.search-bar form {
display: flex;
gap: 0.4rem;
}
.search-bar input[type="text"] {
flex: 1;
padding: 0.5rem 0.7rem;
font-family: 'Georgia', serif;
font-size: 0.9rem;
border: 1px solid var(--border);
border-radius: 3px;
background: #fff;
color: var(--text);
outline: none;
}
.search-bar input[type="text"]:focus {
border-color: var(--stone-color);
}
.search-bar button {
padding: 0.5rem 0.75rem;
font-family: 'Georgia', serif;
font-size: 0.85rem;
color: var(--stone-text);
background: var(--stone-muted);
border: 1px solid var(--border);
border-radius: 3px;
cursor: pointer;
}
.search-bar button:hover {
background: color-mix(in srgb, var(--stone-color) 20%, #f5f5f0);
}
.random-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
padding: 0.4rem 0.75rem; padding: 0.4rem 0.65rem;
font-family: 'Georgia', serif; font-family: 'Georgia', serif;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--stone-text); color: var(--stone-text);
@@ -271,6 +347,13 @@
text-decoration: underline; text-decoration: underline;
} }
.page-footer .mineral-count {
display: block;
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.page-footer .home-link { .page-footer .home-link {
display: inline-block; display: inline-block;
margin-top: 1rem; margin-top: 1rem;
@@ -313,20 +396,34 @@
{% if mineral %} {% if mineral %}
<header class="page-header"> <header class="page-header">
<a href="{% url 'dailystone:random_stone' %}" class="random-btn" title="Random mineral"> <div class="header-actions">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <a href="#" class="search-toggle" title="Search minerals" onclick="document.getElementById('searchBar').classList.toggle('open');document.getElementById('searchInput').focus();return false;">
<polyline points="16 3 21 3 21 8"></polyline><line x1="4" y1="20" x2="21" y2="3"></line> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="21 16 21 21 16 21"></polyline><line x1="15" y1="15" x2="21" y2="21"></line> <circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="4" y1="4" x2="9" y2="9"></line> </svg>
</svg> </a>
Random <a href="{% url 'dailystone:random_stone' %}" class="random-btn" title="Random mineral">
</a> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 3 21 3 21 8"></polyline><line x1="4" y1="20" x2="21" y2="3"></line>
<polyline points="21 16 21 21 16 21"></polyline><line x1="15" y1="15" x2="21" y2="21"></line>
<line x1="4" y1="4" x2="9" y2="9"></line>
</svg>
Random
</a>
</div>
<div class="label">{% if is_random %}Random Stone{% else %}Daily Stone{% endif %}</div> <div class="label">{% if is_random %}Random Stone{% else %}Daily Stone{% endif %}</div>
<h1 class="mineral-name">{{ mineral.name }}</h1> <h1 class="mineral-name">{{ mineral.name }}</h1>
{% if mineral.formula %}<div class="formula">{{ mineral.formula|chem_formula }}</div>{% endif %} {% if mineral.formula %}<div class="formula">{{ mineral.formula|chem_formula }}</div>{% endif %}
<div class="date">{{ today|date:"F j, Y" }}</div> <div class="date">{{ today|date:"F j, Y" }}</div>
</header> </header>
<div class="search-bar" id="searchBar">
<form action="{% url 'dailystone:search' %}" method="get">
<input type="text" id="searchInput" name="q" placeholder="Search minerals...">
<button type="submit">Go</button>
</form>
</div>
<!-- Gallery --> <!-- Gallery -->
<div class="gallery"> <div class="gallery">
{% if mineral.image_urls %} {% if mineral.image_urls %}
@@ -431,6 +528,9 @@
Read more on Wikipedia &rarr; Read more on Wikipedia &rarr;
</a> </a>
{% endif %} {% endif %}
{% if total_minerals %}
<span class="mineral-count">{{ total_minerals }} minerals in collection</span>
{% endif %}
<br> <br>
<a href="/" class="home-link">k-boris.tech</a> <a href="/" class="home-link">k-boris.tech</a>
</footer> </footer>

View File

@@ -7,4 +7,6 @@ app_name = 'dailystone'
urlpatterns = [ urlpatterns = [
path('', views.daily_stone, name='daily_stone'), path('', views.daily_stone, name='daily_stone'),
path('random/', views.random_stone, name='random_stone'), path('random/', views.random_stone, name='random_stone'),
path('search/', views.search_minerals, name='search'),
path('mineral/<int:pk>/', views.mineral_detail, name='mineral_detail'),
] ]

View File

@@ -1,11 +1,14 @@
from datetime import date from datetime import date
from django.shortcuts import render, redirect from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse
from .models import Mineral from .models import Mineral
def _base_context():
return {'total_minerals': Mineral.objects.count()}
def daily_stone(request): def daily_stone(request):
today = date.today() today = date.today()
day = today.timetuple().tm_yday day = today.timetuple().tm_yday
@@ -14,11 +17,9 @@ def daily_stone(request):
if total == 0: if total == 0:
return render(request, 'dailystone/stone.html', {'mineral': None}) return render(request, 'dailystone/stone.html', {'mineral': None})
# Wrap around if we have fewer minerals than days in the year
index = (day - 1) % total + 1 index = (day - 1) % total + 1
mineral = Mineral.objects.filter(day_of_year=index).first() mineral = Mineral.objects.filter(day_of_year=index).first()
# Fallback: pick by modulo of pk list
if not mineral: if not mineral:
minerals = list(Mineral.objects.all()) minerals = list(Mineral.objects.all())
mineral = minerals[(day - 1) % len(minerals)] mineral = minerals[(day - 1) % len(minerals)]
@@ -26,6 +27,7 @@ def daily_stone(request):
return render(request, 'dailystone/stone.html', { return render(request, 'dailystone/stone.html', {
'mineral': mineral, 'mineral': mineral,
'today': today, 'today': today,
**_base_context(),
}) })
@@ -37,4 +39,31 @@ def random_stone(request):
'mineral': mineral, 'mineral': mineral,
'today': date.today(), 'today': date.today(),
'is_random': True, 'is_random': True,
**_base_context(),
})
def mineral_detail(request, pk):
mineral = get_object_or_404(Mineral, pk=pk)
return render(request, 'dailystone/stone.html', {
'mineral': mineral,
'today': date.today(),
**_base_context(),
})
def search_minerals(request):
query = request.GET.get('q', '').strip()
if not query:
return redirect('dailystone:daily_stone')
results = Mineral.objects.filter(name__icontains=query)
if results.count() == 1:
return redirect('dailystone:mineral_detail', pk=results.first().pk)
return render(request, 'dailystone/search.html', {
'query': query,
'results': results,
**_base_context(),
}) })