feat: add image search with Bing, DuckDuckGo, and Qwant engines
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 6s
Mirror to GitHub / mirror (push) Failing after 3s
Tests / test (push) Successful in 25s

Three new image search engines:
- bing_images: Bing Images via RSS endpoint
- ddg_images: DuckDuckGo Images via VQD API
- qwant_images: Qwant Images via v3 search API

Frontend:
- Image grid layout with responsive columns
- image_item template with thumbnail, title, and source metadata
- Hover animations and lazy loading
- Grid activates automatically when category=images

Backend:
- category=images routes to image engines via planner
- Image engines registered in factory and engine allowlist
- extractImgSrc helper for parsing thumbnail URLs from HTML
- IsImageSearch flag on PageData for template layout switching
This commit is contained in:
Franz Kafka 2026-03-22 16:49:24 +00:00
parent a316763aca
commit 2b072e4de3
11 changed files with 687 additions and 4 deletions

View file

@ -952,6 +952,100 @@ footer a:hover {
}
}
/* ============================================================
Image Results
============================================================ */
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
.image-result {
display: block;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg-secondary);
border: 1px solid var(--border);
transition: transform 0.15s ease, box-shadow 0.15s ease;
text-decoration: none;
color: inherit;
}
.image-result:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px var(--shadow);
}
.image-result:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.image-thumb {
aspect-ratio: 1;
overflow: hidden;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
}
.image-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.2s ease;
}
.image-result:hover .image-thumb img {
transform: scale(1.05);
}
.image-thumb.image-error img,
.image-thumb.image-error {
display: none;
}
.image-placeholder {
font-size: 2rem;
opacity: 0.3;
}
.image-meta {
padding: 0.5rem;
min-height: 2.5rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.image-title {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-source {
font-size: 0.7rem;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 480px) {
.image-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.5rem;
}
}
/* ============================================================
Infoboxes
============================================================ */

View file

@ -0,0 +1,15 @@
{{define "image_item"}}
<a class="image-result" href="{{.URL}}" target="_blank" rel="noopener noreferrer">
<div class="image-thumb">
{{if .Thumbnail}}
<img src="{{.Thumbnail}}" alt="{{.Title}}" loading="lazy" onerror="this.parentElement.classList.add('image-error')">
{{else}}
<div class="image-placeholder">🖼️</div>
{{end}}
</div>
<div class="image-meta">
<span class="image-title">{{.Title}}</span>
{{if .Content}}<span class="image-source">{{.Content}}</span>{{end}}
</div>
</a>
{{end}}

View file

@ -19,13 +19,25 @@
<div id="urls" role="main">
{{if .Results}}
{{if .IsImageSearch}}
<div class="image-grid">
{{range .Results}}
{{if eq .Template "images"}}
{{template "image_item" .}}
{{end}}
{{end}}
</div>
{{else}}
{{range .Results}}
{{if eq .Template "videos"}}
{{template "video_item" .}}
{{else if eq .Template "images"}}
{{template "image_item" .}}
{{else}}
{{template "result_item" .}}
{{end}}
{{end}}
{{end}}
{{else if not .Answers}}
<div class="no-results">
<div class="no-results-icon">🔍</div>

View file

@ -52,6 +52,7 @@ type PageData struct {
UnresponsiveEngines [][2]string
PageNumbers []PageNumber
ShowHeader bool
IsImageSearch bool
// New fields for three-column layout
Categories []string
CategoryIcons map[string]string
@ -106,13 +107,13 @@ func init() {
}
tmplFull = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
"base.html", "results.html", "results_inner.html", "result_item.html", "video_item.html",
"base.html", "results.html", "results_inner.html", "result_item.html", "video_item.html", "image_item.html",
))
tmplIndex = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
"base.html", "index.html",
))
tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
"results.html", "results_inner.html", "result_item.html", "video_item.html",
"results.html", "results_inner.html", "result_item.html", "video_item.html", "image_item.html",
))
tmplPreferences = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
"base.html", "preferences.html",
@ -168,6 +169,7 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int, activ
"weather": "🌤️",
},
ActiveCategory: activeCategory,
IsImageSearch: activeCategory == "images",
// Time filters
TimeFilters: []FilterOption{