fix(ui): tag counts, j/k nav, stream layout, search alignment #15
@@ -13,7 +13,8 @@ type TagResponse struct {
|
|||||||
|
|
||||||
func listTags(store *db.Store) http.HandlerFunc {
|
func listTags(store *db.Store) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
tags, err := store.ListTags()
|
cardsOnly := r.URL.Query().Get("cards_only") == "true"
|
||||||
|
tags, err := store.ListTags(cardsOnly)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalError(w, err)
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
|
|||||||
+6
-2
@@ -5,12 +5,16 @@ type TagCount struct {
|
|||||||
Count int
|
Count int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListTags() ([]TagCount, error) {
|
func (s *Store) ListTags(cardsOnly bool) ([]TagCount, error) {
|
||||||
|
where := "WHERE e.deleted_at IS NULL"
|
||||||
|
if cardsOnly {
|
||||||
|
where += " AND e.card_type IS NOT NULL"
|
||||||
|
}
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT t.tag, COUNT(*) as cnt
|
SELECT t.tag, COUNT(*) as cnt
|
||||||
FROM entity_tags t
|
FROM entity_tags t
|
||||||
JOIN entities e ON t.entity_id = e.id
|
JOIN entities e ON t.entity_id = e.id
|
||||||
WHERE e.deleted_at IS NULL
|
` + where + `
|
||||||
GROUP BY t.tag
|
GROUP BY t.tag
|
||||||
ORDER BY t.tag`)
|
ORDER BY t.tag`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import "testing"
|
|||||||
|
|
||||||
func TestListTags_Empty(t *testing.T) {
|
func TestListTags_Empty(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
tags, err := s.ListTags()
|
tags, err := s.ListTags(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ func TestListTags_Counts(t *testing.T) {
|
|||||||
s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"ops"}})
|
s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"ops"}})
|
||||||
s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"home"}})
|
s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"home"}})
|
||||||
|
|
||||||
tags, err := s.ListTags()
|
tags, err := s.ListTags(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ func TestListTags_ExcludesDeleted(t *testing.T) {
|
|||||||
|
|
||||||
s.Create(&Entity{Body: "alive", Glyph: GlyphNote, Tags: []string{"here"}})
|
s.Create(&Entity{Body: "alive", Glyph: GlyphNote, Tags: []string{"here"}})
|
||||||
|
|
||||||
tags, err := s.ListTags()
|
tags, err := s.ListTags(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -61,3 +61,37 @@ func TestListTags_ExcludesDeleted(t *testing.T) {
|
|||||||
t.Errorf("expected 'here', got %q", tags[0].Tag)
|
t.Errorf("expected 'here', got %q", tags[0].Tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListTags_CardsOnly(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
s.Create(&Entity{Body: "fluid", Glyph: GlyphNote, Tags: []string{"ops", "shared"}})
|
||||||
|
|
||||||
|
ct := CardSnippet
|
||||||
|
s.Create(&Entity{Body: "card", Glyph: GlyphNote, Tags: []string{"ops", "code"}, CardType: &ct})
|
||||||
|
|
||||||
|
all, err := s.ListTags(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(all) != 3 {
|
||||||
|
t.Fatalf("all tags: expected 3, got %d", len(all))
|
||||||
|
}
|
||||||
|
|
||||||
|
cards, err := s.ListTags(true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(cards) != 2 {
|
||||||
|
t.Fatalf("card tags: expected 2, got %d", len(cards))
|
||||||
|
}
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, tc := range cards {
|
||||||
|
counts[tc.Tag] = tc.Count
|
||||||
|
}
|
||||||
|
if counts["ops"] != 1 {
|
||||||
|
t.Errorf("ops count: expected 1 (card only), got %d", counts["ops"])
|
||||||
|
}
|
||||||
|
if counts["code"] != 1 {
|
||||||
|
t.Errorf("code count: expected 1, got %d", counts["code"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+31
-12
@@ -110,8 +110,10 @@
|
|||||||
});
|
});
|
||||||
return resp.json();
|
return resp.json();
|
||||||
},
|
},
|
||||||
async listTags() {
|
async listTags(params = {}) {
|
||||||
const resp = await fetch('/api/tags');
|
const q = new URLSearchParams();
|
||||||
|
if (params.cards_only) q.set('cards_only', 'true');
|
||||||
|
const resp = await fetch('/api/tags?' + q);
|
||||||
return resp.json();
|
return resp.json();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -653,9 +655,9 @@
|
|||||||
const descSnip = e.description ? `<span class="entity-desc">${escHtml(e.description)}</span>` : '';
|
const descSnip = e.description ? `<span class="entity-desc">${escHtml(e.description)}</span>` : '';
|
||||||
if (e.title) {
|
if (e.title) {
|
||||||
const preview = e.body ? `<span class="entity-preview">${escHtml(e.body)}</span>` : '';
|
const preview = e.body ? `<span class="entity-preview">${escHtml(e.body)}</span>` : '';
|
||||||
label = `<span class="entity-title">${escHtml(e.title)}</span>${descSnip}${preview}`;
|
label = `<span class="entity-content"><span class="entity-title">${escHtml(e.title)}</span>${descSnip}${preview}</span>`;
|
||||||
} else {
|
} else {
|
||||||
label = `<span class="entity-body">${escHtml(e.body)}</span>${descSnip}`;
|
label = `<span class="entity-content"><span class="entity-body">${escHtml(e.body)}</span>${descSnip}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<div class="entity-item${selected}${isCard}" data-index="${idx}" data-id="${e.id}">
|
return `<div class="entity-item${selected}${isCard}" data-index="${idx}" data-id="${e.id}">
|
||||||
@@ -1124,6 +1126,7 @@
|
|||||||
state.selectedIndex = -1;
|
state.selectedIndex = -1;
|
||||||
renderEntityList();
|
renderEntityList();
|
||||||
renderDetailPane();
|
renderDetailPane();
|
||||||
|
renderTagRail();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
@@ -1168,7 +1171,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTags() {
|
async function loadTags() {
|
||||||
state.tags = await api.listTags();
|
const params = {};
|
||||||
|
if (state.view === 'cards') params.cards_only = true;
|
||||||
|
state.tags = await api.listTags(params);
|
||||||
renderTagRail();
|
renderTagRail();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,10 +1183,10 @@
|
|||||||
state.selectedIndex = -1;
|
state.selectedIndex = -1;
|
||||||
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
|
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
|
||||||
window.location.hash = view === 'cards' ? '/cards' : '/';
|
window.location.hash = view === 'cards' ? '/cards' : '/';
|
||||||
loadEntities();
|
|
||||||
renderMonthNav();
|
renderMonthNav();
|
||||||
renderTagRail();
|
|
||||||
renderCaptureBar();
|
renderCaptureBar();
|
||||||
|
loadEntities();
|
||||||
|
loadTags();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Toast ==========
|
// ========== Toast ==========
|
||||||
@@ -1430,16 +1435,30 @@
|
|||||||
const sel = state.entities[state.selectedIndex];
|
const sel = state.entities[state.selectedIndex];
|
||||||
|
|
||||||
switch (ev.key) {
|
switch (ev.key) {
|
||||||
case 'j':
|
case 'j': {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
selectEntity(Math.min(state.selectedIndex + 1, state.entities.length - 1));
|
const visible = filterBySearch(state.entities);
|
||||||
|
const sel = state.entities[state.selectedIndex];
|
||||||
|
const curPos = sel ? visible.indexOf(sel) : -1;
|
||||||
|
const nextPos = Math.min(curPos + 1, visible.length - 1);
|
||||||
|
if (visible.length > 0 && nextPos >= 0) {
|
||||||
|
selectEntity(state.entities.indexOf(visible[nextPos]));
|
||||||
|
}
|
||||||
scrollSelectedIntoView();
|
scrollSelectedIntoView();
|
||||||
break;
|
break;
|
||||||
case 'k':
|
}
|
||||||
|
case 'k': {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
selectEntity(Math.max(state.selectedIndex - 1, 0));
|
const visible = filterBySearch(state.entities);
|
||||||
|
const sel = state.entities[state.selectedIndex];
|
||||||
|
const curPos = sel ? visible.indexOf(sel) : -1;
|
||||||
|
const prevPos = Math.max(curPos - 1, 0);
|
||||||
|
if (visible.length > 0) {
|
||||||
|
selectEntity(state.entities.indexOf(visible[prevPos]));
|
||||||
|
}
|
||||||
scrollSelectedIntoView();
|
scrollSelectedIntoView();
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'n':
|
case 'n':
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
$('#capture-input').focus();
|
$('#capture-input').focus();
|
||||||
@@ -1485,7 +1504,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function scrollSelectedIntoView() {
|
function scrollSelectedIntoView() {
|
||||||
const el = $(`.entity-item[data-index="${state.selectedIndex}"]`);
|
const el = $(`.entity-item[data-index="${state.selectedIndex}"], .card-row[data-index="${state.selectedIndex}"]`);
|
||||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ nav { display: flex; gap: 2px; }
|
|||||||
.header-search {
|
.header-search {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#search-input {
|
#search-input {
|
||||||
@@ -363,6 +364,14 @@ main {
|
|||||||
.glyph-decision { color: var(--note); }
|
.glyph-decision { color: var(--note); }
|
||||||
.glyph-link { color: var(--event); }
|
.glyph-link { color: var(--event); }
|
||||||
|
|
||||||
|
.entity-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.entity-title {
|
.entity-title {
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -370,6 +379,8 @@ main {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-desc {
|
.entity-desc {
|
||||||
@@ -381,6 +392,8 @@ main {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-preview {
|
.entity-preview {
|
||||||
@@ -391,10 +404,13 @@ main {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-body {
|
.entity-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
Reference in New Issue
Block a user