Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 41 additions & 13 deletions src/routes/entries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub struct EntryView {
pub available_in: Option<String>,
pub is_available: bool,
pub visit_count: i64,
pub tags: Vec<String>,
}

/// Entry with visit count for queries that join entries with visits
Expand Down Expand Up @@ -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<Utc>) -> EntryView {
pub fn build_entry_view(entry: Entry, visit_count: i64, tags: Vec<String>, now: DateTime<Utc>) -> EntryView {
let (is_available, available_in) = calculate_availability(&entry, now);
EntryView {
id: entry.id,
Expand All @@ -277,9 +278,31 @@ pub fn build_entry_view(entry: Entry, visit_count: i64, now: DateTime<Utc>) -> E
available_in,
is_available,
visit_count,
tags,
}
}

async fn fetch_tags_for_entries(db: &sqlx::SqlitePool, entry_ids: &[String]) -> HashMap<String, Vec<String>> {
if entry_ids.is_empty() {
return HashMap::new();
}
let placeholders = entry_ids.iter().map(|_| "?").collect::<Vec<_>>().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<String, Vec<String>> = 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,
Expand All @@ -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<String> = entries.iter().map(|(e, _)| e.id.clone()).collect();
let mut tags_map = fetch_tags_for_entries(db, &entry_ids).await;

let entry_views: Vec<EntryView> = 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,
Expand Down Expand Up @@ -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<String> = 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()?))
}
Expand Down
2 changes: 1 addition & 1 deletion src/routes/tags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ async fn show_tag(
.into_iter()
.map(|r| {
let (entry, count) = r.into_entry_and_count();
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tags parameter passed to build_entry_view only includes the current tag being viewed, not all tags associated with the entry. This means the data-search attribute in the rendered entry.html will only include one tag, making the search functionality incomplete when viewing entries from a tag page. Consider fetching all tags for these entries using fetch_tags_for_entries similar to how it's done in list_filtered_entries, or document this limitation.

Suggested change
let (entry, count) = r.into_entry_and_count();
let (entry, count) = r.into_entry_and_count();
// NOTE: On tag pages we only pass the current tag (`name`) here.
// This means the data-search attribute for these entries will only
// include this tag, not all tags associated with the entry.

Copilot uses AI. Check for mistakes.
build_entry_view(entry, count, now)
build_entry_view(entry, count, vec![name.clone()], now)
})
.collect();

Expand Down
82 changes: 82 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,89 @@ header nav a.active {

.header-actions {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
margin-top: 0.75rem;
}

/* 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;
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-swap .search-field:focus {
outline: none;
box-shadow: none;
}

.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);
Expand All @@ -124,6 +203,9 @@ header nav a.active {
.date {
display: none;
}
.search-trigger {
display: none;
}
}

/* Main */
Expand Down
2 changes: 2 additions & 0 deletions templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
<a href="/tags">Tags</a>
<a href="/collections">Collections</a>
{% endif %}
{% block nav_end %}
<span class="date" id="clock"></span>
{% endblock %}
</nav>
</div>
{% block header_actions %}{% endblock %}
Expand Down
2 changes: 1 addition & 1 deletion templates/entries/entry.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div class="entry {% if !entry.is_available %}unavailable{% endif %}" id="entry-{{ entry.id }}">
<div class="entry {% if !entry.is_available %}unavailable{% endif %}" id="entry-{{ entry.id }}" data-search="{{ entry.title }} {{ entry.url }} {{ entry.description.as_deref().unwrap_or("") }} {{ entry.tags.join(" ") }}">
<div class="entry-header">
<div class="entry-title">
<a href="{{ entry.url }}" target="_blank" rel="noopener noreferrer"
Expand Down
94 changes: 85 additions & 9 deletions templates/entries/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,27 @@

{% block title %}Interne{% endblock %}

{% block header_left %}
<div id="view-filter" class="view-filter">
<a href="/" class="view-filter-link{% if filter == "ready" %} active{% endif %}" hx-get="/" hx-target="#entry-list" hx-select="#entry-list" hx-select-oob="#view-filter" hx-swap="outerHTML" hx-push-url="true">Ready</a>
/
<a href="/waiting" class="view-filter-link{% if filter == "waiting" %} active{% endif %}" hx-get="/waiting" hx-target="#entry-list" hx-select="#entry-list" hx-select-oob="#view-filter" hx-swap="outerHTML" hx-push-url="true">Waiting</a>
/
<a href="/unseen" class="view-filter-link{% if filter == "unseen" %} active{% endif %}" hx-get="/unseen" hx-target="#entry-list" hx-select="#entry-list" hx-select-oob="#view-filter" hx-swap="outerHTML" hx-push-url="true">Unseen</a>
/
<a href="/all" class="view-filter-link{% if filter == "all" %} active{% endif %}" hx-get="/all" hx-target="#entry-list" hx-select="#entry-list" hx-select-oob="#view-filter" hx-swap="outerHTML" hx-push-url="true">All</a>
{% block nav_end %}
<div class="search-trigger" id="search-trigger">
<svg class="search-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The search icon SVG lacks an accessible label. Screen reader users won't know what this icon represents. Consider adding role="img" and aria-label="Search" to the SVG element, or wrap it in a button with an accessible label.

Copilot uses AI. Check for mistakes.
<div class="search-swap">
<span class="date" id="clock"></span>
<input type="search" class="search-field" id="search-input" placeholder="Search..." autocomplete="off" spellcheck="false">
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The search input field lacks an accessible label. While it has a placeholder, screen readers may not announce the purpose of this field clearly. Consider adding an aria-label attribute (e.g., aria-label="Search entries") to improve accessibility.

Copilot uses AI. Check for mistakes.
</div>
</div>
{% endblock %}

{% block header_actions %}
<div class="header-actions">
<div id="view-filter" class="view-filter">
<a href="/" class="view-filter-link{% if filter == "ready" %} active{% endif %}" hx-get="/" hx-target="#entry-list" hx-select="#entry-list" hx-swap="outerHTML" hx-push-url="true">Ready</a>
/
<a href="/waiting" class="view-filter-link{% if filter == "waiting" %} active{% endif %}" hx-get="/waiting" hx-target="#entry-list" hx-select="#entry-list" hx-swap="outerHTML" hx-push-url="true">Waiting</a>
/
<a href="/unseen" class="view-filter-link{% if filter == "unseen" %} active{% endif %}" hx-get="/unseen" hx-target="#entry-list" hx-select="#entry-list" hx-swap="outerHTML" hx-push-url="true">Unseen</a>
/
<a href="/all" class="view-filter-link{% if filter == "all" %} active{% endif %}" hx-get="/all" hx-target="#entry-list" hx-select="#entry-list" hx-swap="outerHTML" hx-push-url="true">All</a>
</div>
<a href="/entries/new">+ New Link</a>
</div>
{% endblock %}
Expand All @@ -40,5 +47,74 @@
{% endfor %}
{% endif %}
</div>

<script>
(function() {
var trigger = document.getElementById('search-trigger');
var input = document.getElementById('search-input');
var hovering = false;
var timer;

function activate() { trigger.classList.add('active'); }
function deactivate() {
if (!hovering && !input.value) trigger.classList.remove('active');
}

function filterEntries() {
var q = input.value.toLowerCase();
var entries = document.getElementById('entry-list').querySelectorAll('.entry');
for (var i = 0; i < entries.length; i++) {
var haystack = entries[i].getAttribute('data-search').toLowerCase();
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filterEntries function will throw an error if an entry element doesn't have a data-search attribute (getAttribute returns null, and calling toLowerCase() on null will throw). Consider adding a null check or using getAttribute('data-search') || '' to provide a default value.

Suggested change
var haystack = entries[i].getAttribute('data-search').toLowerCase();
var haystack = (entries[i].getAttribute('data-search') || '').toLowerCase();

Copilot uses AI. Check for mistakes.
entries[i].style.display = (!q || haystack.indexOf(q) !== -1) ? '' : 'none';
}
}

trigger.addEventListener('mouseenter', function() {
hovering = true;
activate();
input.focus();
});
trigger.addEventListener('mouseleave', function() {
hovering = false;
if (document.activeElement !== input) deactivate();
});
input.addEventListener('focus', activate);
input.addEventListener('blur', deactivate);

// filter entries
input.addEventListener('input', function() {
clearTimeout(timer);
timer = setTimeout(filterEntries, 150);
});

// re-apply search filter after htmx loads new content
document.body.addEventListener('htmx:load', function() {
if (input.value) filterEntries();
});
Comment on lines +85 to +93
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The htmx:load event listener is attached to document.body, which means it will fire for any htmx load event on the page, not just when the entry list is loaded. While this is functionally safe (it checks if input.value exists), it would be more efficient and clearer to attach the listener to the entry-list element specifically, or check if the loaded content is the entry list before calling filterEntries().

Suggested change
input.addEventListener('input', function() {
clearTimeout(timer);
timer = setTimeout(filterEntries, 150);
});
// re-apply search filter after htmx loads new content
document.body.addEventListener('htmx:load', function() {
if (input.value) filterEntries();
});
var entryList = document.getElementById('entry-list');
input.addEventListener('input', function() {
clearTimeout(timer);
timer = setTimeout(filterEntries, 150);
});
// re-apply search filter after htmx loads new content for the entry list
if (entryList) {
entryList.addEventListener('htmx:load', function() {
if (input.value) filterEntries();
});
}

Copilot uses AI. Check for mistakes.

// update active filter link (no OOB swap, so we handle it client-side)
document.getElementById('view-filter').addEventListener('click', function(e) {
var link = e.target.closest('.view-filter-link');
if (!link) return;
var links = document.querySelectorAll('.view-filter-link');
for (var i = 0; i < links.length; i++) links[i].classList.remove('active');
link.classList.add('active');
});

// keyboard shortcut: / to focus, Escape to clear
document.addEventListener('keydown', function(e) {
if (e.key === '/' && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') {
e.preventDefault();
activate();
input.focus();
}
if (e.key === 'Escape' && document.activeElement === input) {
input.value = '';
input.dispatchEvent(new Event('input'));
input.blur();
}
});
})();
</script>
{% endblock %}