Redesign dashboard: refined-minimal layout with product thumbnails
Move the dashboard from boxed glass cards to an airy, hairline-separated layout where type and whitespace carry the page, then surface listing images as the visual anchor. - Solid surfaces (drop backdrop-filter glass, per spec); calm flat field with a single cool top-glow replacing the drifting aurora and dot-grid. - Recent results / recent alerts are now product feeds with 52px thumbnails, listing titles, and price; image_url threaded through ResultRow/AlertRow (data already stored, just unsurfaced). - ListResults gains an OnlyAlerted filter so the alerts panel reuses its decryption path instead of a raw query. - Dashboard recent results capped at 8 for a balanced snapshot.
This commit is contained in:
@@ -56,14 +56,17 @@ type ResultRow struct {
|
||||
Currency string
|
||||
Source string
|
||||
URL string
|
||||
ImageURL string
|
||||
FoundAt time.Time
|
||||
Alerted bool
|
||||
}
|
||||
|
||||
type AlertRow struct {
|
||||
ItemName string
|
||||
Title string
|
||||
Price *float64
|
||||
Currency string
|
||||
ImageURL string
|
||||
FoundAt time.Time
|
||||
}
|
||||
|
||||
@@ -74,64 +77,71 @@ type AlertRow struct {
|
||||
// Layout would inject a nested page (and a duplicate sidebar) into the div.
|
||||
templ DashboardBody(d DashboardData) {
|
||||
<div hx-get="/dashboard/refresh" hx-trigger="every 60s" hx-swap="outerHTML">
|
||||
<h1 class="text-3xl font-semibold mb-6">Dashboard</h1>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
@statCard("Total Items", fmt.Sprintf("%d", d.Stats.TotalItems), "")
|
||||
@statCard("Active", fmt.Sprintf("%d", d.Stats.ActiveItems), "")
|
||||
@statCard("Results Today", fmt.Sprintf("%d", d.Stats.ResultsToday), "")
|
||||
@statCard("Alerts Today", fmt.Sprintf("%d", d.Stats.AlertsToday), "")
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-4 mb-6">
|
||||
<div class="v-card p-5">
|
||||
<div class="v-muted text-sm uppercase tracking-wide">Potential Spend</div>
|
||||
<div class="font-mono text-4xl mt-2" data-countup>{ fmt.Sprintf("$%.2f", d.Stats.PotentialSpend) }</div>
|
||||
<div class="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.PricedItemCount) } items</div>
|
||||
if d.Stats.UnpricedCount > 0 {
|
||||
<div class="v-muted text-xs mt-1">{ fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount) }</div>
|
||||
}
|
||||
<header class="mb-10">
|
||||
<h1 class="v-page-title">Dashboard</h1>
|
||||
<p class="v-page-sub">{ fmt.Sprintf("Watching %d items · %d active", d.Stats.TotalItems, d.Stats.ActiveItems) }</p>
|
||||
</header>
|
||||
<div class="grid md:grid-cols-2 gap-8 mb-8">
|
||||
<div>
|
||||
<div class="v-metric-label">Potential spend</div>
|
||||
<div class="v-metric-value" data-countup>{ fmt.Sprintf("$%.2f", d.Stats.PotentialSpend) }</div>
|
||||
<div class="v-metric-sub">
|
||||
{ fmt.Sprintf("across %d priced items", d.Stats.PricedItemCount) }
|
||||
if d.Stats.UnpricedCount > 0 {
|
||||
{ fmt.Sprintf(" · %d not yet priced", d.Stats.UnpricedCount) }
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="v-card p-5">
|
||||
<div class="v-muted text-sm uppercase tracking-wide">Money Saved</div>
|
||||
<div class="font-mono text-4xl mt-2 v-price-deal" data-countup>{ fmt.Sprintf("$%.2f", d.Stats.MoneySaved) }</div>
|
||||
<div class="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items, vs 30-day average</div>
|
||||
<div>
|
||||
<div class="v-metric-label">Money saved</div>
|
||||
<div class="v-metric-value v-price-deal" data-countup>{ fmt.Sprintf("$%.2f", d.Stats.MoneySaved) }</div>
|
||||
<div class="v-metric-sub">{ fmt.Sprintf("across %d items, vs 30-day average", d.Stats.SavedItemCount) }</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="v-card p-5 mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="font-semibold">API Usage and Budget</h2>
|
||||
<hr class="v-rule mb-8"/>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 mb-8">
|
||||
@statBlock("Total items", fmt.Sprintf("%d", d.Stats.TotalItems))
|
||||
@statBlock("Active", fmt.Sprintf("%d", d.Stats.ActiveItems))
|
||||
@statBlock("Results today", fmt.Sprintf("%d", d.Stats.ResultsToday))
|
||||
@statBlock("Alerts today", fmt.Sprintf("%d", d.Stats.AlertsToday))
|
||||
</div>
|
||||
<hr class="v-rule mb-8"/>
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-5">
|
||||
<h2 class="v-section-title">API usage and budget</h2>
|
||||
<span class="v-muted text-xs">Apify runs cost credits. Visible to everyone.</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<div class="v-muted text-xs uppercase tracking-wide">Apify (month)</div>
|
||||
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("%d", d.ApifyMonth) }</div>
|
||||
<div class="v-stat-label">Apify (month)</div>
|
||||
<div class="v-stat-value">{ fmt.Sprintf("%d", d.ApifyMonth) }</div>
|
||||
if d.ApifyCostPerCall > 0 {
|
||||
<div class="v-muted text-xs mt-1">{ fmt.Sprintf("~$%.2f est.", d.ApifyMonthCost()) }</div>
|
||||
<div class="v-metric-sub">{ fmt.Sprintf("~$%.2f estimated", d.ApifyMonthCost()) }</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<div class="v-muted text-xs uppercase tracking-wide">Apify (today)</div>
|
||||
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("%d", d.ApifyToday) }</div>
|
||||
<div class="v-stat-label">Apify (today)</div>
|
||||
<div class="v-stat-value">{ fmt.Sprintf("%d", d.ApifyToday) }</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="v-muted text-xs uppercase tracking-wide">eBay (today)</div>
|
||||
<div class="v-stat-label">eBay (today)</div>
|
||||
if d.EbayLimit > 0 {
|
||||
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("%d / %d", d.EbayToday, d.EbayLimit) }</div>
|
||||
<div class="v-stat-value">{ fmt.Sprintf("%d / %d", d.EbayToday, d.EbayLimit) }</div>
|
||||
} else {
|
||||
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("%d", d.EbayToday) }</div>
|
||||
<div class="v-stat-value">{ fmt.Sprintf("%d", d.EbayToday) }</div>
|
||||
}
|
||||
</div>
|
||||
if d.MonthlyBudget > 0 {
|
||||
<div>
|
||||
<div class="v-muted text-xs uppercase tracking-wide">Monthly Budget</div>
|
||||
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("$%.0f", d.MonthlyBudget) }</div>
|
||||
<div class="v-stat-label">Monthly budget</div>
|
||||
<div class="v-stat-value">{ fmt.Sprintf("$%.0f", d.MonthlyBudget) }</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
if d.MonthlyBudget > 0 {
|
||||
<div class="mt-4">
|
||||
<div class="mt-6 max-w-md">
|
||||
<progress class="v-progress w-full" value={ fmt.Sprintf("%d", d.BudgetPercent()) } max="100"></progress>
|
||||
<div class="flex justify-between text-xs mt-1">
|
||||
<div class="flex justify-between text-xs mt-2">
|
||||
<span class="v-muted">{ fmt.Sprintf("%d%% of budget used", d.BudgetPercent()) }</span>
|
||||
if d.BudgetOver() {
|
||||
<span class="v-price-target">Over budget</span>
|
||||
@@ -139,56 +149,84 @@ templ DashboardBody(d DashboardData) {
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="v-card p-5">
|
||||
<h2 class="font-semibold mb-3">Recent Results</h2>
|
||||
</section>
|
||||
<hr class="v-rule mb-8"/>
|
||||
<div class="grid md:grid-cols-2 gap-10">
|
||||
<section>
|
||||
<h2 class="v-section-title mb-4">Recent results</h2>
|
||||
if len(d.RecentResults) == 0 {
|
||||
<div class="v-muted text-sm">No results yet.</div>
|
||||
<div class="v-empty">Nothing found yet. Veola is watching.</div>
|
||||
} else {
|
||||
<table class="v-table">
|
||||
<thead>
|
||||
<tr><th>Item</th><th>Price</th><th>Source</th><th>Found</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, r := range d.RecentResults {
|
||||
<tr>
|
||||
<td><a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)) }>{ r.ItemName }</a></td>
|
||||
<td class="font-mono">{ fmtPrice(r.Price, r.Currency) }</td>
|
||||
<td>{ r.Source }</td>
|
||||
<td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
<div class="v-card p-5">
|
||||
<h2 class="font-semibold mb-3">Recent Alerts</h2>
|
||||
if len(d.RecentAlerts) == 0 {
|
||||
<div class="v-muted text-sm">No alerts sent yet.</div>
|
||||
} else {
|
||||
<ul class="space-y-2">
|
||||
for _, a := range d.RecentAlerts {
|
||||
<li class="flex justify-between items-center border-b border-white/10 pb-2">
|
||||
<span>{ a.ItemName }</span>
|
||||
<span class="font-mono v-price-target">{ fmtPrice(a.Price, a.Currency) }</span>
|
||||
<ul class="v-feed">
|
||||
for _, r := range d.RecentResults {
|
||||
<li class="v-feed-row">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)) }>
|
||||
@thumb(r.ImageURL, r.Source)
|
||||
</a>
|
||||
<div class="min-w-0 flex-1">
|
||||
<a class="v-feed-name" href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)) }>{ r.ItemName }</a>
|
||||
<div class="v-feed-meta truncate">{ resultMeta(r) }</div>
|
||||
</div>
|
||||
<div class="font-mono v-feed-price">{ fmtPrice(r.Price, r.Currency) }</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2 class="v-section-title mb-4">Recent alerts</h2>
|
||||
if len(d.RecentAlerts) == 0 {
|
||||
<div class="v-empty">No alerts sent. Nothing has hit target.</div>
|
||||
} else {
|
||||
<ul class="v-feed">
|
||||
for _, a := range d.RecentAlerts {
|
||||
<li class="v-feed-row">
|
||||
@thumb(a.ImageURL, "")
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="v-feed-name truncate">{ a.ItemName }</div>
|
||||
if a.Title != "" {
|
||||
<div class="v-feed-meta truncate">{ a.Title }</div>
|
||||
}
|
||||
</div>
|
||||
<div class="font-mono v-feed-price v-price-target">{ fmtPrice(a.Price, a.Currency) }</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ statCard(label, value, sub string) {
|
||||
<div class="v-card p-4">
|
||||
<div class="v-muted text-xs uppercase tracking-wide">{ label }</div>
|
||||
<div class="font-mono text-3xl mt-1" data-countup>{ value }</div>
|
||||
if sub != "" {
|
||||
<div class="v-muted text-xs mt-1">{ sub }</div>
|
||||
}
|
||||
// thumb renders a product thumbnail, falling back to a quiet placeholder
|
||||
// (the source initial) when a listing has no image. Marketplace images come
|
||||
// from an open set of CDNs, which the CSP's img-src allows.
|
||||
templ thumb(url, source string) {
|
||||
if url != "" {
|
||||
<img class="v-thumb" src={ url } loading="lazy" alt="" referrerpolicy="no-referrer"/>
|
||||
} else {
|
||||
<div class="v-thumb v-thumb-empty">{ sourceInitial(source) }</div>
|
||||
}
|
||||
}
|
||||
|
||||
func resultMeta(r ResultRow) string {
|
||||
if r.Title != "" {
|
||||
return r.Title
|
||||
}
|
||||
return r.Source + " · " + humanTime(r.FoundAt)
|
||||
}
|
||||
|
||||
func sourceInitial(source string) string {
|
||||
if source == "" {
|
||||
return ""
|
||||
}
|
||||
return string([]rune(source)[:1])
|
||||
}
|
||||
|
||||
templ statBlock(label, value string) {
|
||||
<div>
|
||||
<div class="v-stat-label">{ label }</div>
|
||||
<div class="v-stat-value" data-countup>{ value }</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user