From 4dca449271f51ce95163bbf61d0c0815f9789142 Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Tue, 17 Feb 2026 00:01:18 -0500 Subject: [PATCH 1/2] feat(search): add client-side search filtering for entries list --- src/routes/entries.rs | 54 +++++++++++++++++++++++++++--------- src/routes/tags.rs | 2 +- static/style.css | 21 ++++++++++++++ templates/entries/entry.html | 2 +- templates/entries/list.html | 20 +++++++++++++ 5 files changed, 84 insertions(+), 15 deletions(-) diff --git a/src/routes/entries.rs b/src/routes/entries.rs index ac45681..366efcf 100644 --- a/src/routes/entries.rs +++ b/src/routes/entries.rs @@ -39,6 +39,7 @@ pub struct EntryView { pub available_in: Option, pub is_available: bool, pub visit_count: i64, + pub tags: Vec, } /// Entry with visit count for queries that join entries with visits @@ -266,7 +267,7 @@ async fn fetch_entries_for_user(db: &sqlx::SqlitePool, user_id: &str) -> Vec<(En entries.into_iter().map(|e| e.into_entry_and_count()).collect() } -pub fn build_entry_view(entry: Entry, visit_count: i64, now: DateTime) -> EntryView { +pub fn build_entry_view(entry: Entry, visit_count: i64, tags: Vec, now: DateTime) -> EntryView { let (is_available, available_in) = calculate_availability(&entry, now); EntryView { id: entry.id, @@ -277,9 +278,31 @@ pub fn build_entry_view(entry: Entry, visit_count: i64, now: DateTime) -> E available_in, is_available, visit_count, + tags, } } +async fn fetch_tags_for_entries(db: &sqlx::SqlitePool, entry_ids: &[String]) -> HashMap> { + if entry_ids.is_empty() { + return HashMap::new(); + } + let placeholders = entry_ids.iter().map(|_| "?").collect::>().join(","); + let query = format!( + "SELECT et.entry_id, t.name FROM entry_tags et JOIN tags t ON t.id = et.tag_id WHERE et.entry_id IN ({})", + placeholders + ); + let mut q = sqlx::query_as::<_, (String, String)>(&query); + for id in entry_ids { + q = q.bind(id); + } + let rows: Vec<(String, String)> = q.fetch_all(db).await.unwrap_or_default(); + let mut map: HashMap> = HashMap::new(); + for (entry_id, tag_name) in rows { + map.entry(entry_id).or_default().push(tag_name); + } + map +} + async fn list_filtered_entries( db: &sqlx::SqlitePool, user: User, @@ -288,9 +311,15 @@ async fn list_filtered_entries( let entries = fetch_entries_for_user(db, &user.id).await; let now = Utc::now(); + let entry_ids: Vec = entries.iter().map(|(e, _)| e.id.clone()).collect(); + let mut tags_map = fetch_tags_for_entries(db, &entry_ids).await; + let entry_views: Vec = entries .into_iter() - .map(|(entry, visit_count)| build_entry_view(entry, visit_count, now)) + .map(|(entry, visit_count)| { + let tags = tags_map.remove(&entry.id).unwrap_or_default(); + build_entry_view(entry, visit_count, tags, now) + }) .filter(|ev| match filter { "ready" => ev.is_available, "waiting" => !ev.is_available, @@ -390,19 +419,18 @@ async fn visit_entry( .await?; let now_dt = Utc::now(); - let (is_available, available_in) = calculate_availability(&entry, now_dt); + + let tags: Vec<(String,)> = sqlx::query_as( + "SELECT t.name FROM tags t JOIN entry_tags et ON et.tag_id = t.id WHERE et.entry_id = ?" + ) + .bind(&entry.id) + .fetch_all(&state.db) + .await + .unwrap_or_default(); + let tags: Vec = tags.into_iter().map(|(name,)| name).collect(); let template = EntryTemplate { - entry: EntryView { - id: entry.id, - url: entry.url, - title: entry.title, - description: entry.description, - last_viewed: format_last_viewed(&entry.dismissed_at, now_dt), - available_in, - is_available, - visit_count: visit_count.0, - }, + entry: build_entry_view(entry, visit_count.0, tags, now_dt), }; Ok(Html(template.render()?)) } diff --git a/src/routes/tags.rs b/src/routes/tags.rs index fe85c2e..9f3b065 100644 --- a/src/routes/tags.rs +++ b/src/routes/tags.rs @@ -164,7 +164,7 @@ async fn show_tag( .into_iter() .map(|r| { let (entry, count) = r.into_entry_and_count(); - build_entry_view(entry, count, now) + build_entry_view(entry, count, vec![name.clone()], now) }) .collect(); diff --git a/static/style.css b/static/style.css index 853e0a3..8d66b88 100644 --- a/static/style.css +++ b/static/style.css @@ -110,10 +110,31 @@ header nav a.active { margin-top: 0.75rem; } +#search-input { + flex: 1; + padding: 0.25rem 0.5rem; + border: var(--border); + border-radius: var(--radius); + font-size: 0.8125rem; + font-family: inherit; + background: var(--white); + color: var(--black); +} + +#search-input:focus { + outline: none; + border-color: var(--gray-400); +} + +#search-input::placeholder { + color: var(--gray-400); +} + .header-actions a { font-size: 0.8125rem; color: var(--gray-600); text-decoration: none; + white-space: nowrap; } .header-actions a:hover { diff --git a/templates/entries/entry.html b/templates/entries/entry.html index f8615aa..e3c1347 100644 --- a/templates/entries/entry.html +++ b/templates/entries/entry.html @@ -1,4 +1,4 @@ -
+
+ + New Link
{% endblock %} @@ -40,5 +41,24 @@ {% endfor %} {% endif %}
+ + {% endblock %} From d1c4bcbda70f4dee0db09a2fb3a3cf58192c45d4 Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Tue, 17 Feb 2026 01:21:14 -0500 Subject: [PATCH 2/2] fix(search): preserve client-side search filter across htmx filter swaps Remove hx-select-oob on view-filter links to prevent detaching the triggering element from the DOM. Use htmx:load event to re-apply search filtering after new content is loaded. Handle active filter class toggle in JS instead of server-rendered OOB swap. --- static/style.css | 81 ++++++++++++++++++++++++++++---- templates/base.html | 2 + templates/entries/list.html | 94 +++++++++++++++++++++++++++++-------- 3 files changed, 148 insertions(+), 29 deletions(-) diff --git a/static/style.css b/static/style.css index 8d66b88..3ee54b5 100644 --- a/static/style.css +++ b/static/style.css @@ -106,35 +106,93 @@ header nav a.active { .header-actions { display: flex; + justify-content: space-between; + align-items: baseline; gap: 1rem; margin-top: 0.75rem; } -#search-input { - flex: 1; - padding: 0.25rem 0.5rem; - border: var(--border); - border-radius: var(--radius); +/* Search trigger */ +.search-trigger { + display: flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; +} + +.search-icon { + color: var(--gray-400); + flex-shrink: 0; + transition: color 0.15s ease; +} + +.search-trigger:hover .search-icon, +.search-trigger.active .search-icon { + color: var(--gray-600); +} + +.search-swap { + display: grid; + min-width: 10rem; + justify-items: end; +} + +.search-swap > * { + grid-row: 1; + grid-column: 1; + transition: opacity 0.15s ease; +} + +.search-trigger:hover .search-swap .date, +.search-trigger.active .search-swap .date { + opacity: 0; + pointer-events: none; +} + +.search-swap .search-field { + -webkit-appearance: none; + appearance: none; + width: 100%; + opacity: 0; + pointer-events: none; font-size: 0.8125rem; font-family: inherit; - background: var(--white); color: var(--black); + background: transparent; + border: none; + border-bottom: 1px solid transparent; + border-radius: 0; + padding: 0 0 1px 0; + text-align: right; + box-shadow: none; } -#search-input:focus { +.search-swap .search-field:focus { outline: none; - border-color: var(--gray-400); + box-shadow: none; } -#search-input::placeholder { +.search-swap .search-field::placeholder { color: var(--gray-400); } +.search-swap .search-field::-webkit-search-cancel-button, +.search-swap .search-field::-webkit-search-decoration { + -webkit-appearance: none; + display: none; +} + +.search-trigger:hover .search-swap .search-field, +.search-trigger.active .search-swap .search-field { + opacity: 1; + pointer-events: auto; + border-bottom-color: var(--gray-200); +} + .header-actions a { font-size: 0.8125rem; color: var(--gray-600); text-decoration: none; - white-space: nowrap; } .header-actions a:hover { @@ -145,6 +203,9 @@ header nav a.active { .date { display: none; } + .search-trigger { + display: none; + } } /* Main */ diff --git a/templates/base.html b/templates/base.html index 4d54c5d..4415ab2 100644 --- a/templates/base.html +++ b/templates/base.html @@ -21,7 +21,9 @@ Tags Collections {% endif %} + {% block nav_end %} + {% endblock %}
{% block header_actions %}{% endblock %} diff --git a/templates/entries/list.html b/templates/entries/list.html index fc10249..5e32d5d 100644 --- a/templates/entries/list.html +++ b/templates/entries/list.html @@ -2,21 +2,27 @@ {% block title %}Interne{% endblock %} -{% block header_left %} -
- Ready - / - Waiting - / - Unseen - / - All +{% block nav_end %} +
+ +
+ + +
{% endblock %} {% block header_actions %}
- +
+ Ready + / + Waiting + / + Unseen + / + All +
+ New Link
{% endblock %} @@ -44,19 +50,69 @@