Global Header Search Proposal

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:

  1. Exact match — title/name equals the search term
  2. Prefix match — title/name starts with the search term
  3. 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 the CASE-based ordering
  • Respects limitPerType per entity + returns totalCount via COUNT(*) on the un-limited result

New file: internal/api/resolver_search.go

  • Implements the GraphQL search resolver
  • 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+K to focus the search box?
  • Fuzzy match / typo tolerance — may want FTS or trigram-based matching later
  • Per-type filter — the types field in SearchInput is ready for a future “search only in Scenes” toggle
  • Multi-select mode — explicitly deferred to a separate plan