Global Header Search Proposal
Feature: A persistent search box in the Stash header that searches across all entity types simultaneously.
Status: Design proposal and feedback requested.
Date: 2026-07-18
Most of the design choices were made to create a search box similar to those on popular sites like GitHub. I tried to cover the fundamental features and edge cases with the help of AI. I am also uploading a mock up file - take it as a grain of salt. It shows you how the proposal will work if it gets implemented. You can download it here.
1. Overview
Add a persistent search input to the top navbar (left side, after the “Stash” brand link). Typing a query performs a debounced (250ms) GraphQL search across Scenes, Performers, Tags, Studios, Galleries, Groups, and Markers in a single unified query. Results appear in a dropdown overlay grouped by entity type, with a “See all N results” link per group that navigates to the entity’s existing list page with the query pre-filled.
2. UX Flow
Desktop
┌──────────────────────────────────────────────────────────────────────┐
│ Stash │ 🔍 Search scenes, performers... │ Scenes Images Groups … │
│ │───────────────────────────────────│ │
│ │ ╔══════════════════════════════════╗ │
│ │ ║ SCENES (12) ║ │
│ │ ║ ┌──────┬──────────────────────┐ ║ │
│ │ ║ │ 🖼 │ Red Dawn │ ║ │
│ │ ║ │ │ Vivid · 1h 32m │ ║ │
│ │ ║ ├──────┼──────────────────────┤ ║ │
│ │ ║ │ 🖼 │ Red Hot Summer │ ║ │
│ │ ║ │ │ Brazzers · 45m │ ║ │
│ │ ║ └──────┴──────────────────────┘ ║ │
│ │ ║ See all 12 scenes → ║ │
│ │ ╠══════════════════════════════════╣ │
│ │ ║ PERFORMERS (3) ║ │
│ │ ║ ┌──────┬──────────────────────┐ ║ │
│ │ ║ │ 👤 │ Red Fox │ ║ │
│ │ ║ └──────┴──────────────────────┘ ║ │
│ │ ║ See all 3 performers → ║ │
│ │ ╠══════════════════════════════════╣ │
│ │ ║ TAGS (1) ║ │
│ │ ║ ┌──────┬──────────────────────┐ ║ │
│ │ ║ │ 🏷 │ red (15 scenes) │ ║ │
│ │ ║ └──────┴──────────────────────┘ ║ │
│ │ ╚══════════════════════════════════╝ │
└──────────────────────────────────────────────────────────────────────┘
States:
| State | Behavior |
|---|---|
| Empty (idle) | Placeholder “Search…” text. Click to focus. Recent searches shown if available. |
| Typing | 250ms debounce → fires search(term) GraphQL query. |
| Loading (>150ms) | Skeleton placeholders appear (card-shaped, matching result layout). |
| Results | Grouped sections, each with entity icon + count badge. |
| No results | Single “No results found for ‘xyzzy’” message (not per-section). |
| Error | Inline error message inside dropdown with retry button. |
| Escape | Closes overlay immediately (does not clear input first). |
3. Mobile
On narrow screens the search box collapses to a magnifying glass icon. Tapping opens a full-screen overlay (auto-focuses input, back/close button top-left) with the same grouped-result layout, full-width.
┌──────────────────────┐
│ ← Search… │
├──────────────────────┤
│ SCENES (12) │
│ ┌──────────────────┐ │
│ │ 🖼 Red Dawn │ │
│ │ Vivid · 1h32m│ │
│ ├──────────────────┤ │
│ │ 🖼 Red Hot Sum. │ │
│ │ Brazzers·45m │ │
│ └──────────────────┘ │
│ See all 12 scenes → │
├──────────────────────┤
│ PERFORMERS (3) │
│ ┌──────────────────┐ │
│ │ 👤 Red Fox │ │
│ └──────────────────┘ │
│ See all 3 performers │
├──────────────────────┤
│ TAGS (1) │
│ ┌──────────────────┐ │
│ │ 🏷 red (15) │ │
│ └──────────────────┘ │
└──────────────────────┘
4. Interaction Design
| Action | Behavior |
|---|---|
| Click search input | Focus input, show dropdown with recent searches (or “Type to search” fallback). |
| Type | 250ms debounce → single search() GraphQL call. |
| Arrow Up/Down | Navigate through ALL results as a continuous list (not per-section). |
| Enter | Navigate to highlighted result’s detail page. |
| Escape | Close overlay (single press, no intermediate clear step). |
| Tab | Default browser behavior (not repurposed for navigation). |
| Click result | Navigate to detail page, close overlay. |
| Click “See all N” | Navigate to entity list page (/scenes?q=term), close overlay. |
| Click outside | Close overlay. |
5. GraphQL Schema
New query
search(input: SearchInput!): SearchResults!
Input types
input SearchInput {
term: String!
limitPerType: Int = 10
types: [SearchEntityType!]
}
enum SearchEntityType {
SCENE
PERFORMER
TAG
STUDIO
GALLERY
GROUP
MARKER
}
Preview types (one per entity)
type ScenePreview {
id: ID!
title: String!
thumbnailPath: String
studioName: String
duration: Int
}
type PerformerPreview {
id: ID!
name: String!
imagePath: String
}
type TagPreview {
id: ID!
name: String!
sceneCount: Int
}
type StudioPreview {
id: ID!
name: String!
imagePath: String
}
type GalleryPreview {
id: ID!
title: String!
coverPath: String
}
type GroupPreview {
id: ID!
name: String!
thumbnailPath: String
}
type MarkerPreview {
id: ID!
title: String!
sceneName: String
seconds: Float
}
Result type
type SearchResults {
scenes: [ScenePreview!]!
performers: [PerformerPreview!]!
tags: [TagPreview!]!
studios: [StudioPreview!]!
galleries: [GalleryPreview!]!
groups: [GroupPreview!]!
markers: [MarkerPreview!]!
totalCounts: SearchResultCounts!
}
type SearchResultCounts {
scenes: Int!
performers: Int!
tags: Int!
studios: Int!
galleries: Int!
groups: Int!
markers: Int!
}
Relevance ranking (server-side)
Results within each type ordered by:
- Exact match — title/name equals the search term
- Prefix match — title/name starts with the search term
- Substring match — search term appears anywhere else
Tiebreaker: alphabetical. Implemented via a simple CASE expression, no FTS required.
6. Backend Architecture
New file: pkg/sqlite/search.go
- Accepts
SearchInput, executes parallel-ish queries against each requested entity table - Each query uses
WHERE name/title LIKE '%term%'with theCASE-based ordering - Respects
limitPerTypeper entity + returnstotalCountviaCOUNT(*)on the un-limited result
New file: internal/api/resolver_search.go
- Implements the GraphQL
searchresolver - Calls
sqlite.Search(...)and maps results to preview types
7. Frontend Architecture
New components
| File | Purpose |
|---|---|
ui/v2.5/src/components/Search/SearchBar.tsx |
The search input in the navbar |
ui/v2.5/src/components/Search/SearchOverlay.tsx |
The dropdown overlay (results display) |
ui/v2.5/src/components/Search/SearchResultGroup.tsx |
One entity-type section within the overlay |
ui/v2.5/src/components/Search/SearchResultRow.tsx |
One result row (thumbnail + text) |
ui/v2.5/src/components/Search/useSearch.ts |
Hook: debounce, GraphQL call, state management |
ui/v2.5/src/hooks/useRecentSearches.ts |
Hook: persist recent searches to localStorage |
GraphQL client query
query Search($input: SearchInput!) {
search(input: $input) {
scenes { id title thumbnailPath studioName duration }
performers { id name imagePath }
tags { id name sceneCount }
studios { id name imagePath }
galleries { id title coverPath }
groups { id name thumbnailPath }
markers { id title sceneName seconds }
totalCounts { scenes performers tags studios galleries groups markers }
}
}
Integration with MainNavbar.tsx
- The search bar is rendered before the
<Navbar.Collapse>block - Wrapped in a
<div>with flex-grow to push nav menu right - On mobile: swaps to icon-only via CSS media query
8. Dependencies & Impact
| Dependency | Impact |
|---|---|
| New GraphQL query | Add search to schema, update gqlgen.yml, run go generate |
| New SQL queries | pkg/sqlite/search.go — LIKE queries with CASE ordering |
| New UI components | ~5 new files in ui/v2.5/src/components/Search/ |
List page ?q= param |
Already supported by ListFilterModel — no changes needed |
| Header layout | Minor flexbox adjustments in MainNavbar.tsx |
9. Open Questions / Future Considerations
- Search history — should recent searches persist across sessions (localStorage)?
- Keyboard shortcut — a future
Cmd+K/Ctrl+Kto focus the search box? - Fuzzy match / typo tolerance — may want FTS or trigram-based matching later
- Per-type filter — the
typesfield inSearchInputis ready for a future “search only in Scenes” toggle - Multi-select mode — explicitly deferred to a separate plan