feat: add browse-at-scale — date ranges, load more, month navigator
CLI: --month YYYY-MM, --from/--to date range, --limit override API: from/to query params for date range filtering Web: load more button for pagination, month nav (◂/▸) in stream view
This commit is contained in:
@@ -13,6 +13,10 @@ import (
|
|||||||
var (
|
var (
|
||||||
lsTag string
|
lsTag string
|
||||||
lsDate string
|
lsDate string
|
||||||
|
lsMonth string
|
||||||
|
lsFrom string
|
||||||
|
lsTo string
|
||||||
|
lsLimit int
|
||||||
lsAll bool
|
lsAll bool
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +29,10 @@ var lsCmd = &cobra.Command{
|
|||||||
func init() {
|
func init() {
|
||||||
lsCmd.Flags().StringVar(&lsTag, "tag", "", "filter by tag")
|
lsCmd.Flags().StringVar(&lsTag, "tag", "", "filter by tag")
|
||||||
lsCmd.Flags().StringVar(&lsDate, "date", "", "filter by date (YYYY-MM-DD)")
|
lsCmd.Flags().StringVar(&lsDate, "date", "", "filter by date (YYYY-MM-DD)")
|
||||||
|
lsCmd.Flags().StringVar(&lsMonth, "month", "", "filter by month (YYYY-MM)")
|
||||||
|
lsCmd.Flags().StringVar(&lsFrom, "from", "", "start date (YYYY-MM-DD)")
|
||||||
|
lsCmd.Flags().StringVar(&lsTo, "to", "", "end date (YYYY-MM-DD)")
|
||||||
|
lsCmd.Flags().IntVar(&lsLimit, "limit", 0, "max entities to show (default 50)")
|
||||||
lsCmd.Flags().BoolVar(&lsAll, "all", false, "include deleted entities")
|
lsCmd.Flags().BoolVar(&lsAll, "all", false, "include deleted entities")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,9 +49,41 @@ func runLs(_ *cobra.Command, _ []string) error {
|
|||||||
if lsTag != "" {
|
if lsTag != "" {
|
||||||
p.Tag = &lsTag
|
p.Tag = &lsTag
|
||||||
}
|
}
|
||||||
|
if lsLimit > 0 {
|
||||||
|
p.Limit = lsLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
hasDateFilter := false
|
||||||
if lsDate != "" {
|
if lsDate != "" {
|
||||||
p.Date = &lsDate
|
p.Date = &lsDate
|
||||||
} else {
|
hasDateFilter = true
|
||||||
|
}
|
||||||
|
if lsMonth != "" {
|
||||||
|
t, err := time.Parse("2006-01", lsMonth)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bad --month format, use YYYY-MM")
|
||||||
|
}
|
||||||
|
from := t.Format("2006-01-02")
|
||||||
|
to := t.AddDate(0, 1, -1).Format("2006-01-02")
|
||||||
|
p.From = &from
|
||||||
|
p.To = &to
|
||||||
|
hasDateFilter = true
|
||||||
|
}
|
||||||
|
if lsFrom != "" {
|
||||||
|
if _, err := time.Parse("2006-01-02", lsFrom); err != nil {
|
||||||
|
return fmt.Errorf("bad --from format, use YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
p.From = &lsFrom
|
||||||
|
hasDateFilter = true
|
||||||
|
}
|
||||||
|
if lsTo != "" {
|
||||||
|
if _, err := time.Parse("2006-01-02", lsTo); err != nil {
|
||||||
|
return fmt.Errorf("bad --to format, use YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
p.To = &lsTo
|
||||||
|
hasDateFilter = true
|
||||||
|
}
|
||||||
|
if !hasDateFilter {
|
||||||
since := time.Now().UTC().Add(-48 * time.Hour)
|
since := time.Now().UTC().Add(-48 * time.Hour)
|
||||||
p.Since = &since
|
p.Since = &since
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ func listEntities(store *db.Store) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
p.Date = &date
|
p.Date = &date
|
||||||
}
|
}
|
||||||
|
if from := r.URL.Query().Get("from"); from != "" {
|
||||||
|
if _, err := time.Parse("2006-01-02", from); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_input", "bad from format, use YYYY-MM-DD")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.From = &from
|
||||||
|
}
|
||||||
|
if to := r.URL.Query().Get("to"); to != "" {
|
||||||
|
if _, err := time.Parse("2006-01-02", to); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_input", "bad to format, use YYYY-MM-DD")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.To = &to
|
||||||
|
}
|
||||||
if r.URL.Query().Get("cards_only") == "true" {
|
if r.URL.Query().Get("cards_only") == "true" {
|
||||||
p.CardsOnly = true
|
p.CardsOnly = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ type Entity struct {
|
|||||||
type ListParams struct {
|
type ListParams struct {
|
||||||
Tag *string
|
Tag *string
|
||||||
Date *string
|
Date *string
|
||||||
|
From *string
|
||||||
|
To *string
|
||||||
Since *time.Time
|
Since *time.Time
|
||||||
CardsOnly bool
|
CardsOnly bool
|
||||||
IncludeDeleted bool
|
IncludeDeleted bool
|
||||||
@@ -192,6 +194,14 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
|
|||||||
where = append(where, "date(e.created_at) = ?")
|
where = append(where, "date(e.created_at) = ?")
|
||||||
args = append(args, *params.Date)
|
args = append(args, *params.Date)
|
||||||
}
|
}
|
||||||
|
if params.From != nil {
|
||||||
|
where = append(where, "date(e.created_at) >= ?")
|
||||||
|
args = append(args, *params.From)
|
||||||
|
}
|
||||||
|
if params.To != nil {
|
||||||
|
where = append(where, "date(e.created_at) <= ?")
|
||||||
|
args = append(args, *params.To)
|
||||||
|
}
|
||||||
if params.Since != nil {
|
if params.Since != nil {
|
||||||
where = append(where, "e.created_at >= ?")
|
where = append(where, "e.created_at >= ?")
|
||||||
args = append(args, params.Since.Format(time.RFC3339))
|
args = append(args, params.Since.Format(time.RFC3339))
|
||||||
|
|||||||
+76
-3
@@ -14,12 +14,16 @@
|
|||||||
link: 'glyph-link',
|
link: 'glyph-link',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
view: 'stream',
|
view: 'stream',
|
||||||
entities: [],
|
entities: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
selectedIndex: -1,
|
selectedIndex: -1,
|
||||||
activeTag: null,
|
activeTag: null,
|
||||||
|
hasMore: false,
|
||||||
|
activeMonth: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (sel) => document.querySelector(sel);
|
const $ = (sel) => document.querySelector(sel);
|
||||||
@@ -32,9 +36,13 @@
|
|||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (params.tag) q.set('tag', params.tag);
|
if (params.tag) q.set('tag', params.tag);
|
||||||
if (params.date) q.set('date', params.date);
|
if (params.date) q.set('date', params.date);
|
||||||
|
if (params.from) q.set('from', params.from);
|
||||||
|
if (params.to) q.set('to', params.to);
|
||||||
if (params.cards_only) q.set('cards_only', 'true');
|
if (params.cards_only) q.set('cards_only', 'true');
|
||||||
if (params.sort) q.set('sort', params.sort);
|
if (params.sort) q.set('sort', params.sort);
|
||||||
if (params.order) q.set('order', params.order);
|
if (params.order) q.set('order', params.order);
|
||||||
|
if (params.limit) q.set('limit', String(params.limit));
|
||||||
|
if (params.offset) q.set('offset', String(params.offset));
|
||||||
const resp = await fetch('/api/entities?' + q);
|
const resp = await fetch('/api/entities?' + q);
|
||||||
return resp.json();
|
return resp.json();
|
||||||
},
|
},
|
||||||
@@ -226,6 +234,10 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.hasMore) {
|
||||||
|
html += '<div class="load-more-wrap"><button class="load-more-btn">load more</button></div>';
|
||||||
|
}
|
||||||
|
|
||||||
list.innerHTML = html;
|
list.innerHTML = html;
|
||||||
|
|
||||||
list.querySelectorAll('.entity-item').forEach(el => {
|
list.querySelectorAll('.entity-item').forEach(el => {
|
||||||
@@ -233,6 +245,9 @@
|
|||||||
selectEntity(parseInt(el.dataset.index));
|
selectEntity(parseInt(el.dataset.index));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const loadMoreBtn = list.querySelector('.load-more-btn');
|
||||||
|
if (loadMoreBtn) loadMoreBtn.addEventListener('click', loadMore);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEntityItem(e, idx) {
|
function renderEntityItem(e, idx) {
|
||||||
@@ -388,8 +403,8 @@
|
|||||||
renderDetailPane();
|
renderDetailPane();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEntities() {
|
function buildListParams(offset) {
|
||||||
const params = {};
|
const params = { limit: PAGE_SIZE, offset: offset || 0 };
|
||||||
if (state.activeTag) params.tag = state.activeTag;
|
if (state.activeTag) params.tag = state.activeTag;
|
||||||
if (state.view === 'cards') {
|
if (state.view === 'cards') {
|
||||||
params.cards_only = true;
|
params.cards_only = true;
|
||||||
@@ -399,13 +414,68 @@
|
|||||||
params.sort = 'created';
|
params.sort = 'created';
|
||||||
params.order = 'desc';
|
params.order = 'desc';
|
||||||
}
|
}
|
||||||
|
if (state.activeMonth) {
|
||||||
|
const [y, m] = state.activeMonth.split('-').map(Number);
|
||||||
|
params.from = state.activeMonth + '-01';
|
||||||
|
const last = new Date(y, m, 0).getDate();
|
||||||
|
params.to = state.activeMonth + '-' + String(last).padStart(2, '0');
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
state.entities = await api.listEntities(params);
|
async function loadEntities() {
|
||||||
|
const params = buildListParams(0);
|
||||||
|
const results = await api.listEntities(params);
|
||||||
|
state.entities = results;
|
||||||
|
state.hasMore = results.length === PAGE_SIZE;
|
||||||
state.selectedIndex = -1;
|
state.selectedIndex = -1;
|
||||||
renderEntityList();
|
renderEntityList();
|
||||||
renderDetailPane();
|
renderDetailPane();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
const params = buildListParams(state.entities.length);
|
||||||
|
const results = await api.listEntities(params);
|
||||||
|
state.entities = state.entities.concat(results);
|
||||||
|
state.hasMore = results.length === PAGE_SIZE;
|
||||||
|
renderEntityList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMonthNav() {
|
||||||
|
const nav = $('#month-nav');
|
||||||
|
if (state.view !== 'stream') { nav.innerHTML = ''; return; }
|
||||||
|
|
||||||
|
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
||||||
|
const label = state.activeMonth
|
||||||
|
? (() => { const [y, m] = state.activeMonth.split('-'); return MONTHS[parseInt(m) - 1] + ' ' + y; })()
|
||||||
|
: 'all time';
|
||||||
|
|
||||||
|
nav.innerHTML = `
|
||||||
|
<button class="month-nav-btn" id="month-prev">◂</button>
|
||||||
|
<span class="month-nav-label">${label}</span>
|
||||||
|
<button class="month-nav-btn" id="month-next">▸</button>
|
||||||
|
${state.activeMonth ? '<button class="month-nav-clear">clear</button>' : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
$('#month-prev').addEventListener('click', () => shiftMonth(-1));
|
||||||
|
$('#month-next').addEventListener('click', () => shiftMonth(1));
|
||||||
|
const clearBtn = nav.querySelector('.month-nav-clear');
|
||||||
|
if (clearBtn) clearBtn.addEventListener('click', () => { state.activeMonth = null; loadEntities(); renderMonthNav(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftMonth(dir) {
|
||||||
|
if (!state.activeMonth) {
|
||||||
|
const now = new Date();
|
||||||
|
state.activeMonth = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
} else {
|
||||||
|
const [y, m] = state.activeMonth.split('-').map(Number);
|
||||||
|
const d = new Date(y, m - 1 + dir, 1);
|
||||||
|
state.activeMonth = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
}
|
||||||
|
loadEntities();
|
||||||
|
renderMonthNav();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTags() {
|
async function loadTags() {
|
||||||
state.tags = await api.listTags();
|
state.tags = await api.listTags();
|
||||||
renderTagRail();
|
renderTagRail();
|
||||||
@@ -413,9 +483,11 @@
|
|||||||
|
|
||||||
function switchView(view) {
|
function switchView(view) {
|
||||||
state.view = view;
|
state.view = view;
|
||||||
|
state.activeMonth = null;
|
||||||
$$('.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();
|
loadEntities();
|
||||||
|
renderMonthNav();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Public API (for inline handlers) ==========
|
// ========== Public API (for inline handlers) ==========
|
||||||
@@ -687,6 +759,7 @@
|
|||||||
async function init() {
|
async function init() {
|
||||||
await Promise.all([loadEntities(), loadTags()]);
|
await Promise.all([loadEntities(), loadTags()]);
|
||||||
handleHash();
|
handleHash();
|
||||||
|
renderMonthNav();
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
+4
-1
@@ -22,7 +22,10 @@
|
|||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<aside id="tag-rail"></aside>
|
<aside id="tag-rail"></aside>
|
||||||
<section id="entity-list"></section>
|
<section id="entity-panel">
|
||||||
|
<div id="month-nav"></div>
|
||||||
|
<div id="entity-list"></div>
|
||||||
|
</section>
|
||||||
<aside id="detail-pane">
|
<aside id="detail-pane">
|
||||||
<div class="detail-empty">select an entity</div>
|
<div class="detail-empty">select an entity</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -153,10 +153,65 @@ main {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Entity panel */
|
||||||
|
#entity-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#month-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#month-nav:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-nav-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-nav-btn:hover { color: var(--text); background: var(--bg-hover); }
|
||||||
|
|
||||||
|
.month-nav-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-nav-clear {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-nav-clear:hover { color: var(--text); }
|
||||||
|
|
||||||
/* Entity list */
|
/* Entity list */
|
||||||
#entity-list {
|
#entity-list {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-header {
|
.date-header {
|
||||||
@@ -483,6 +538,29 @@ main {
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Load more */
|
||||||
|
.load-more-wrap {
|
||||||
|
padding: 12px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-btn {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 6px 24px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Absorb modal */
|
/* Absorb modal */
|
||||||
.absorb-list {
|
.absorb-list {
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
|
|||||||
Reference in New Issue
Block a user