Add mineral search and permalink pages
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
220
dailystone/templates/dailystone/search.html
Normal file
220
dailystone/templates/dailystone/search.html
Normal 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' %}">← 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>
|
||||||
@@ -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,6 +396,12 @@
|
|||||||
{% if mineral %}
|
{% if mineral %}
|
||||||
|
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="#" class="search-toggle" title="Search minerals" onclick="document.getElementById('searchBar').classList.toggle('open');document.getElementById('searchInput').focus();return false;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
<a href="{% url 'dailystone:random_stone' %}" class="random-btn" title="Random mineral">
|
<a href="{% url 'dailystone:random_stone' %}" class="random-btn" title="Random mineral">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<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="16 3 21 3 21 8"></polyline><line x1="4" y1="20" x2="21" y2="3"></line>
|
||||||
@@ -321,12 +410,20 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Random
|
Random
|
||||||
</a>
|
</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 →
|
Read more on Wikipedia →
|
||||||
</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>
|
||||||
|
|||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user