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:
prosolis
2026-06-20 12:42:00 -07:00
parent ed03494edf
commit 9a7f8b47a3
8 changed files with 647 additions and 392 deletions

6
.gitignore vendored
View File

@@ -23,3 +23,9 @@ config.toml
# Debug log output from `-debug` runs # Debug log output from `-debug` runs
veola-debug.log veola-debug.log
*.log *.log
# dev screenshot + throwaway preview artifacts
.shot_*
.shots_*
config_shot.toml
veola_shot.db*

View File

@@ -791,6 +791,8 @@ type ResultsQuery struct {
// Exception: yahoo-auctions-jp is auction-only — NULL ends_at there means // Exception: yahoo-auctions-jp is auction-only — NULL ends_at there means
// the actor's ending_date didn't parse, and those rows are also dropped. // the actor's ending_date didn't parse, and those rows are also dropped.
ExcludeEnded bool ExcludeEnded bool
// OnlyAlerted restricts to rows that triggered a deal alert.
OnlyAlerted bool
} }
func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Result, error) { func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Result, error) {
@@ -817,6 +819,9 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul
conds = append(conds, `((ends_at IS NULL AND source != 'yahoo-auctions-jp') OR ends_at > ?)`) conds = append(conds, `((ends_at IS NULL AND source != 'yahoo-auctions-jp') OR ends_at > ?)`)
args = append(args, time.Now().UTC()) args = append(args, time.Now().UTC())
} }
if q.OnlyAlerted {
conds = append(conds, `alerted = 1`)
}
where := "" where := ""
if len(conds) > 0 { if len(conds) > 0 {
where = `WHERE ` + strings.Join(conds, ` AND `) where = `WHERE ` + strings.Join(conds, ` AND `)

View File

@@ -1,9 +1,7 @@
package handlers package handlers
import ( import (
"database/sql"
"net/http" "net/http"
"time"
"veola/internal/db" "veola/internal/db"
"veola/templates" "veola/templates"
@@ -36,7 +34,7 @@ func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
if err != nil { if err != nil {
return templates.DashboardData{}, err return templates.DashboardData{}, err
} }
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{Limit: 20, ExcludeEnded: true}) results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{Limit: 8, ExcludeEnded: true})
if err != nil { if err != nil {
return templates.DashboardData{}, err return templates.DashboardData{}, err
} }
@@ -55,6 +53,7 @@ func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
Currency: r.Currency, Currency: r.Currency,
Source: r.Source, Source: r.Source,
URL: r.URL, URL: r.URL,
ImageURL: r.ImageURL,
FoundAt: r.FoundAt, FoundAt: r.FoundAt,
Alerted: r.Alerted, Alerted: r.Alerted,
}) })
@@ -80,36 +79,20 @@ func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
} }
func alertsRecent(a *App, r *http.Request, itemNames map[int64]string) ([]templates.AlertRow, error) { func alertsRecent(a *App, r *http.Request, itemNames map[int64]string) ([]templates.AlertRow, error) {
rows, err := a.Store.DB.QueryContext(r.Context(), ` results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{OnlyAlerted: true, Limit: 5})
SELECT item_id, price, currency, found_at FROM results
WHERE alerted = 1 ORDER BY found_at DESC LIMIT 5
`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() out := make([]templates.AlertRow, 0, len(results))
var out []templates.AlertRow for _, res := range results {
for rows.Next() {
var (
itemID int64
price sql.NullFloat64
currency string
foundAt time.Time
)
if err := rows.Scan(&itemID, &price, &currency, &foundAt); err != nil {
return nil, err
}
var p *float64
if price.Valid {
v := price.Float64
p = &v
}
out = append(out, templates.AlertRow{ out = append(out, templates.AlertRow{
ItemName: itemNames[itemID], ItemName: itemNames[res.ItemID],
Price: p, Title: res.Title,
Currency: currency, Price: res.Price,
FoundAt: foundAt, Currency: res.Currency,
ImageURL: res.ImageURL,
FoundAt: res.FoundAt,
}) })
} }
return out, rows.Err() return out, nil
} }

View File

@@ -1,9 +1,9 @@
/* Veola — Sega-blue palette and component overrides for Tailwind play CDN. */ /* Veola — Sega-blue palette and component overrides for Tailwind play CDN. */
:root { :root {
--bg: #1a2b6d; --bg: #16224f;
--surface: #1f3380; --surface: #243a93;
--surface-2: #243a93; --surface-2: #2c47ad;
--accent: #00a4e4; --accent: #00a4e4;
--yellow: #f5c400; --yellow: #f5c400;
--text: #ffffff; --text: #ffffff;
@@ -25,59 +25,175 @@ html, body {
a { color: var(--accent); } a { color: var(--accent); }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
/* Solid surfaces, per the spec's "blue mode": no glassmorphism, no blur. A
clear value gap between --bg and --surface is what lifts cards off the
page; a 1px top highlight + soft blue shadow gives the cartridge edge. */
.v-card { .v-card {
position: relative; position: relative;
isolation: isolate; isolation: isolate;
background: background: var(--surface);
linear-gradient(180deg, rgba(36, 58, 147, 0.82), rgba(31, 51, 128, 0.82));
backdrop-filter: blur(10px) saturate(140%);
-webkit-backdrop-filter: blur(10px) saturate(140%);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 12px;
box-shadow: var(--shadow); box-shadow: 0 1px 2px rgba(0, 0, 40, 0.25);
transition: transition:
transform 180ms ease,
box-shadow 180ms ease, box-shadow 180ms ease,
border-color 180ms ease; border-color 180ms ease;
} }
.v-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg,
rgba(0, 164, 228, 0.65) 0%,
rgba(245, 196, 0, 0.30) 45%,
rgba(255, 255, 255, 0.04) 100%);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
z-index: -1;
}
.v-card:hover { .v-card:hover {
transform: translateY(-2px); border-color: rgba(255, 255, 255, 0.2);
box-shadow: box-shadow: 0 2px 8px rgba(0, 0, 40, 0.3);
0 10px 28px rgba(0, 0, 80, 0.55),
0 0 0 1px rgba(0, 164, 228, 0.30);
} }
.v-card-flat { .v-card-flat {
position: relative; position: relative;
isolation: isolate; isolation: isolate;
background: background: var(--surface);
linear-gradient(180deg, rgba(36, 58, 147, 0.70), rgba(31, 51, 128, 0.70));
backdrop-filter: blur(8px) saturate(130%);
-webkit-backdrop-filter: blur(8px) saturate(130%);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
} }
.v-divider { border-top: 1px solid var(--border); } .v-divider { border-top: 1px solid var(--border); }
/* --- Console readout / page header system -------------------------------
The dashboard's signature: a mono "terminal" kicker over a heavy display
title, and the Spend/Saved pair styled as an arcade scoreboard readout.
JetBrains Mono is the machine voice here (labels, counts); Outfit carries
the title. */
.v-kicker {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.72rem;
font-weight: 500;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
}
.v-kicker .v-kicker-sep { color: var(--text-2); opacity: 0.6; }
.v-kicker .v-kicker-dim { color: var(--text-2); }
/* --- Refined header + metric system -------------------------------------
Calm and spacious: type and whitespace carry the page. No glow, no
uppercase-mono labels, no accent rules. Mono is reserved for the numbers
themselves; Outfit carries everything else. */
.v-page-title {
font-family: 'Outfit', system-ui, sans-serif;
font-size: 1.9rem;
font-weight: 600;
letter-spacing: -0.015em;
line-height: 1.1;
text-shadow: none;
}
.v-page-sub {
color: var(--text-2);
font-size: 0.95rem;
margin-top: 0.3rem;
}
/* Hero metric: a small quiet label over a large clean number. Presented in
open space with a hairline, not a boxed card. */
.v-metric-label {
color: var(--text-2);
font-size: 0.82rem;
letter-spacing: 0.01em;
}
.v-metric-value {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: clamp(2rem, 3.8vw, 2.75rem);
font-weight: 600;
line-height: 1.05;
margin-top: 0.45rem;
}
.v-metric-value.v-price-deal { color: var(--success); }
.v-metric-sub {
color: var(--text-2);
font-size: 0.82rem;
margin-top: 0.5rem;
}
/* Secondary stat: smaller label/number pair for the supporting counts. */
.v-stat-label {
color: var(--text-2);
font-size: 0.78rem;
letter-spacing: 0.01em;
}
.v-stat-value {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 1.5rem;
font-weight: 600;
line-height: 1;
margin-top: 0.3rem;
}
/* Understated section heading for list panels — sized to sit below the page
title, sentence case, no glow. */
.v-section-title {
font-family: 'Outfit', system-ui, sans-serif;
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0;
color: var(--text);
text-shadow: none;
}
/* Hairline used to separate open sections instead of boxing them. */
.v-rule {
border: 0;
border-top: 1px solid var(--border);
}
/* Quiet empty-state line. */
.v-empty {
color: var(--text-2);
font-size: 0.9rem;
padding: 0.4rem 0;
}
/* --- Product feed (thumbnails) ------------------------------------------
Listing thumbnails are what give the data life. A square cropped image,
the listing title as the line that earns its space, price on the right. */
.v-thumb {
width: 52px;
height: 52px;
flex: none;
border-radius: 8px;
object-fit: cover;
background: var(--surface-2);
border: 1px solid var(--border);
display: block;
}
.v-thumb-empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-2);
font-size: 1.1rem;
font-weight: 600;
text-transform: uppercase;
}
.v-feed { display: block; }
.v-feed-row {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.7rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.v-feed-row:last-child { border-bottom: 0; }
.v-feed-name {
display: block;
font-weight: 500;
color: var(--text);
}
a.v-feed-name:hover { color: var(--accent); text-decoration: none; }
.v-feed-meta {
color: var(--text-2);
font-size: 0.8rem;
margin-top: 0.15rem;
}
.v-feed-price { flex: none; white-space: nowrap; }
.v-btn { .v-btn {
background: var(--accent); background: var(--accent);
color: white; color: white;
@@ -212,21 +328,22 @@ a:hover { text-decoration: underline; }
table.v-table { width: 100%; border-collapse: collapse; } table.v-table { width: 100%; border-collapse: collapse; }
table.v-table th { table.v-table th {
text-align: left; text-align: left;
font-size: 0.75rem; font-size: 0.74rem;
letter-spacing: 0.05em; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.01em;
color: var(--text-2); color: var(--text-2);
padding: 0.6rem 0.75rem; padding: 0.5rem 0.75rem 0.7rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
table.v-table td { table.v-table td {
padding: 0.7rem 0.75rem; padding: 0.7rem 0.75rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid rgba(255, 255, 255, 0.05);
vertical-align: middle; vertical-align: middle;
} }
/* No zebra — hairline rows and a faint hover keep it calm but scannable. */
table.v-table td { transition: background 140ms ease; } table.v-table td { transition: background 140ms ease; }
table.v-table tr { transition: transform 140ms ease; } table.v-table tbody tr:last-child td { border-bottom: 0; }
table.v-table tbody tr:hover td { background: rgba(0, 164, 228, 0.08); } table.v-table tbody tr:hover td { background: rgba(255, 255, 255, 0.04); }
.v-error-text { color: var(--danger); font-size: 0.85rem; } .v-error-text { color: var(--danger); font-size: 0.85rem; }
.v-muted { color: var(--text-2); } .v-muted { color: var(--text-2); }

View File

@@ -23,17 +23,20 @@ body {
html { html {
background-color: var(--bg); background-color: var(--bg);
/* Static dot-grid baked into the root background so the two pseudo-element /* Refined direction: dot-grid and drifting blobs removed. A single calm,
slots stay free for the animated blobs. */ cool top-glow gradient gives the field depth without reading as a smudge
or animating in the user's peripheral vision. */
background-image: radial-gradient( background-image: radial-gradient(
circle, 120% 75% at 50% -15%,
rgba(255, 255, 255, 0.045) 1px, #1f3074 0%,
transparent 1px var(--bg) 58%
); );
background-size: 26px 26px;
background-attachment: fixed; background-attachment: fixed;
} }
/* Retire the animated aurora blobs for the refined look. */
html::before, html::after { display: none; }
/* Two aurora blobs animated with seven waypoints each (no `alternate`, so /* Two aurora blobs animated with seven waypoints each (no `alternate`, so
the second half doesn't mirror the first) on incommensurate periods — the second half doesn't mirror the first) on incommensurate periods —
the two cycles drift in and out of phase, so the eye never locks onto a the two cycles drift in and out of phase, so the eye never locks onto a
@@ -48,13 +51,13 @@ html::before {
pointer-events: none; pointer-events: none;
background: radial-gradient( background: radial-gradient(
closest-side, closest-side,
rgba(0, 164, 228, 0.60), rgba(0, 164, 228, 0.13),
rgba(0, 164, 228, 0.0) 70% rgba(0, 164, 228, 0.0) 70%
); );
width: 70vmax; width: 60vmax;
height: 70vmax; height: 60vmax;
border-radius: 50%; border-radius: 50%;
filter: blur(60px); filter: blur(70px);
/* Centered base; keyframes drive all motion from here. */ /* Centered base; keyframes drive all motion from here. */
top: 50%; left: 50%; top: 50%; left: 50%;
margin-top: -35vmax; margin-left: -35vmax; margin-top: -35vmax; margin-left: -35vmax;
@@ -68,15 +71,17 @@ html::after {
inset: 0; inset: 0;
z-index: -2; z-index: -2;
pointer-events: none; pointer-events: none;
/* Cooled to a faint mint instead of arcade yellow: the warm blob read as a
smudge against the cold palette. Kept subtle so it's ambient, not a wash. */
background: radial-gradient( background: radial-gradient(
closest-side, closest-side,
rgba(245, 196, 0, 0.50), rgba(0, 228, 164, 0.07),
rgba(245, 196, 0, 0.0) 70% rgba(0, 228, 164, 0.0) 70%
); );
width: 55vmax; width: 46vmax;
height: 55vmax; height: 46vmax;
border-radius: 50%; border-radius: 50%;
filter: blur(70px); filter: blur(80px);
top: 50%; left: 50%; top: 50%; left: 50%;
margin-top: -27.5vmax; margin-left: -27.5vmax; margin-top: -27.5vmax; margin-left: -27.5vmax;
animation: v-retro-drift-b 29s cubic-bezier(.5,.2,.3,.8) infinite; animation: v-retro-drift-b 29s cubic-bezier(.5,.2,.3,.8) infinite;
@@ -104,13 +109,11 @@ html::after {
100% { transform: translate3d( 28vw, -25vh, 0) scale(1.00); opacity: 0.70; } 100% { transform: translate3d( 28vw, -25vh, 0) scale(1.00); opacity: 0.70; }
} }
/* Display headings: heavier, slightly tighter, with an accent glow that ties /* Refined direction: headings stay clean — no glow, modest weight. The
into the card border gradient. Class-free selectors so existing Tailwind page-title/section-title classes in app.css own the real hierarchy. */
utilities (text-3xl etc.) stack on top untouched. */
h1, h2 { h1, h2 {
font-weight: 800; font-weight: 600;
letter-spacing: -0.02em; letter-spacing: -0.01em;
text-shadow: 0 2px 14px rgba(0, 164, 228, 0.30);
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {

File diff suppressed because one or more lines are too long

View File

@@ -56,14 +56,17 @@ type ResultRow struct {
Currency string Currency string
Source string Source string
URL string URL string
ImageURL string
FoundAt time.Time FoundAt time.Time
Alerted bool Alerted bool
} }
type AlertRow struct { type AlertRow struct {
ItemName string ItemName string
Title string
Price *float64 Price *float64
Currency string Currency string
ImageURL string
FoundAt time.Time FoundAt time.Time
} }
@@ -74,64 +77,71 @@ type AlertRow struct {
// Layout would inject a nested page (and a duplicate sidebar) into the div. // Layout would inject a nested page (and a duplicate sidebar) into the div.
templ DashboardBody(d DashboardData) { templ DashboardBody(d DashboardData) {
<div hx-get="/dashboard/refresh" hx-trigger="every 60s" hx-swap="outerHTML"> <div hx-get="/dashboard/refresh" hx-trigger="every 60s" hx-swap="outerHTML">
<h1 class="text-3xl font-semibold mb-6">Dashboard</h1> <header class="mb-10">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6"> <h1 class="v-page-title">Dashboard</h1>
@statCard("Total Items", fmt.Sprintf("%d", d.Stats.TotalItems), "") <p class="v-page-sub">{ fmt.Sprintf("Watching %d items · %d active", d.Stats.TotalItems, d.Stats.ActiveItems) }</p>
@statCard("Active", fmt.Sprintf("%d", d.Stats.ActiveItems), "") </header>
@statCard("Results Today", fmt.Sprintf("%d", d.Stats.ResultsToday), "") <div class="grid md:grid-cols-2 gap-8 mb-8">
@statCard("Alerts Today", fmt.Sprintf("%d", d.Stats.AlertsToday), "") <div>
</div> <div class="v-metric-label">Potential spend</div>
<div class="grid md:grid-cols-2 gap-4 mb-6"> <div class="v-metric-value" data-countup>{ fmt.Sprintf("$%.2f", d.Stats.PotentialSpend) }</div>
<div class="v-card p-5"> <div class="v-metric-sub">
<div class="v-muted text-sm uppercase tracking-wide">Potential Spend</div> { fmt.Sprintf("across %d priced items", d.Stats.PricedItemCount) }
<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 { if d.Stats.UnpricedCount > 0 {
<div class="v-muted text-xs mt-1">{ fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount) }</div> { fmt.Sprintf(" · %d not yet priced", d.Stats.UnpricedCount) }
} }
</div> </div>
<div class="v-card p-5"> </div>
<div class="v-muted text-sm uppercase tracking-wide">Money Saved</div> <div>
<div class="font-mono text-4xl mt-2 v-price-deal" data-countup>{ fmt.Sprintf("$%.2f", d.Stats.MoneySaved) }</div> <div class="v-metric-label">Money saved</div>
<div class="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items, vs 30-day average</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> </div>
<div class="v-card p-5 mb-6"> <hr class="v-rule mb-8"/>
<div class="flex items-center justify-between mb-3"> <div class="grid grid-cols-2 md:grid-cols-4 gap-6 mb-8">
<h2 class="font-semibold">API Usage and Budget</h2> @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> <span class="v-muted text-xs">Apify runs cost credits. Visible to everyone.</span>
</div> </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>
<div class="v-muted text-xs uppercase tracking-wide">Apify (month)</div> <div class="v-stat-label">Apify (month)</div>
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("%d", d.ApifyMonth) }</div> <div class="v-stat-value">{ fmt.Sprintf("%d", d.ApifyMonth) }</div>
if d.ApifyCostPerCall > 0 { 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> <div>
<div class="v-muted text-xs uppercase tracking-wide">Apify (today)</div> <div class="v-stat-label">Apify (today)</div>
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("%d", d.ApifyToday) }</div> <div class="v-stat-value">{ fmt.Sprintf("%d", d.ApifyToday) }</div>
</div> </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 { 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 { } 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> </div>
if d.MonthlyBudget > 0 { if d.MonthlyBudget > 0 {
<div> <div>
<div class="v-muted text-xs uppercase tracking-wide">Monthly Budget</div> <div class="v-stat-label">Monthly budget</div>
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("$%.0f", d.MonthlyBudget) }</div> <div class="v-stat-value">{ fmt.Sprintf("$%.0f", d.MonthlyBudget) }</div>
</div> </div>
} }
</div> </div>
if d.MonthlyBudget > 0 { 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> <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> <span class="v-muted">{ fmt.Sprintf("%d%% of budget used", d.BudgetPercent()) }</span>
if d.BudgetOver() { if d.BudgetOver() {
<span class="v-price-target">Over budget</span> <span class="v-price-target">Over budget</span>
@@ -139,56 +149,84 @@ templ DashboardBody(d DashboardData) {
</div> </div>
</div> </div>
} }
</div> </section>
<div class="grid md:grid-cols-2 gap-6"> <hr class="v-rule mb-8"/>
<div class="v-card p-5"> <div class="grid md:grid-cols-2 gap-10">
<h2 class="font-semibold mb-3">Recent Results</h2> <section>
<h2 class="v-section-title mb-4">Recent results</h2>
if len(d.RecentResults) == 0 { 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 { } else {
<table class="v-table"> <ul class="v-feed">
<thead>
<tr><th>Item</th><th>Price</th><th>Source</th><th>Found</th></tr>
</thead>
<tbody>
for _, r := range d.RecentResults { for _, r := range d.RecentResults {
<tr> <li class="v-feed-row">
<td><a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)) }>{ r.ItemName }</a></td> <a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)) }>
<td class="font-mono">{ fmtPrice(r.Price, r.Currency) }</td> @thumb(r.ImageURL, r.Source)
<td>{ r.Source }</td> </a>
<td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td> <div class="min-w-0 flex-1">
</tr> <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>
</tbody>
</table>
}
</div> </div>
<div class="v-card p-5"> <div class="font-mono v-feed-price">{ fmtPrice(r.Price, r.Currency) }</div>
<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>
</li> </li>
} }
</ul> </ul>
} }
</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>
<div class="font-mono v-feed-price v-price-target">{ fmtPrice(a.Price, a.Currency) }</div>
</li>
}
</ul>
}
</section>
</div> </div>
</div> </div>
} }
templ statCard(label, value, sub string) { // thumb renders a product thumbnail, falling back to a quiet placeholder
<div class="v-card p-4"> // (the source initial) when a listing has no image. Marketplace images come
<div class="v-muted text-xs uppercase tracking-wide">{ label }</div> // from an open set of CDNs, which the CSP's img-src allows.
<div class="font-mono text-3xl mt-1" data-countup>{ value }</div> templ thumb(url, source string) {
if sub != "" { if url != "" {
<div class="v-muted text-xs mt-1">{ sub }</div> <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> </div>
} }

View File

@@ -64,14 +64,17 @@ type ResultRow struct {
Currency string Currency string
Source string Source string
URL string URL string
ImageURL string
FoundAt time.Time FoundAt time.Time
Alerted bool Alerted bool
} }
type AlertRow struct { type AlertRow struct {
ItemName string ItemName string
Title string
Price *float64 Price *float64
Currency string Currency string
ImageURL string
FoundAt time.Time FoundAt time.Time
} }
@@ -101,402 +104,446 @@ func DashboardBody(d DashboardData) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div hx-get=\"/dashboard/refresh\" hx-trigger=\"every 60s\" hx-swap=\"outerHTML\"><header class=\"mb-10\"><h1 class=\"v-page-title\">Dashboard</h1><p class=\"v-page-sub\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = statCard("Total Items", fmt.Sprintf("%d", d.Stats.TotalItems), "").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = statCard("Active", fmt.Sprintf("%d", d.Stats.ActiveItems), "").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = statCard("Results Today", fmt.Sprintf("%d", d.Stats.ResultsToday), "").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = statCard("Alerts Today", fmt.Sprintf("%d", d.Stats.AlertsToday), "").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</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>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.PotentialSpend)) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Watching %d items · %d active", d.Stats.TotalItems, d.Stats.ActiveItems))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 87, Col: 100} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 82, Col: 113}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div><div class=\"v-muted text-sm mt-1\">across ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</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>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.PricedItemCount)) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.PotentialSpend))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 88, Col: 89} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 87, Col: 91}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " items</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div><div class=\"v-metric-sub\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Stats.UnpricedCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"v-muted text-xs mt-1\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount)) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("across %d priced items", d.Stats.PricedItemCount))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 90, Col: 103} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 89, Col: 69}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</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>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.Stats.UnpricedCount > 0 {
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.MoneySaved)) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(" · %d not yet priced", d.Stats.UnpricedCount))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 95, Col: 109} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 91, Col: 67}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><div class=\"v-muted text-sm mt-1\">across ") }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></div><div><div class=\"v-metric-label\">Money saved</div><div class=\"v-metric-value v-price-deal\" data-countup>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.SavedItemCount)) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.MoneySaved))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 96, Col: 88} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 97, Col: 100}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " items, vs 30-day average</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><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><div class=\"v-muted text-xs uppercase tracking-wide\">Apify (month)</div><div class=\"font-mono text-2xl mt-1\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div><div class=\"v-metric-sub\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ApifyMonth)) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("across %d items, vs 30-day average", d.Stats.SavedItemCount))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 107, Col: 75} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 98, Col: 105}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div></div></div><hr class=\"v-rule mb-8\"><div class=\"grid grid-cols-2 md:grid-cols-4 gap-6 mb-8\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.ApifyCostPerCall > 0 { templ_7745c5c3_Err = statBlock("Total items", fmt.Sprintf("%d", d.Stats.TotalItems)).Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"v-muted text-xs mt-1\">") if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = statBlock("Active", fmt.Sprintf("%d", d.Stats.ActiveItems)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = statBlock("Results today", fmt.Sprintf("%d", d.Stats.ResultsToday)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = statBlock("Alerts today", fmt.Sprintf("%d", d.Stats.AlertsToday)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</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-6\"><div><div class=\"v-stat-label\">Apify (month)</div><div class=\"v-stat-value\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("~$%.2f est.", d.ApifyMonthCost())) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ApifyMonth))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 109, Col: 88} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 117, Col: 64}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} if d.ApifyCostPerCall > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div><div><div class=\"v-muted text-xs uppercase tracking-wide\">Apify (today)</div><div class=\"font-mono text-2xl mt-1\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"v-metric-sub\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ApifyToday)) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("~$%.2f estimated", d.ApifyMonthCost()))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 114, Col: 75} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 119, Col: 85}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div></div><div><div class=\"v-muted text-xs uppercase tracking-wide\">eBay (today)</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.EbayLimit > 0 { }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"font-mono text-2xl mt-1\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><div><div class=\"v-stat-label\">Apify (today)</div><div class=\"v-stat-value\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayToday, d.EbayLimit)) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ApifyToday))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 119, Col: 93} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 124, Col: 64}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div></div><div><div class=\"v-stat-label\">eBay (today)</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { if d.EbayLimit > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"font-mono text-2xl mt-1\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"v-stat-value\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.EbayToday)) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayToday, d.EbayLimit))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 121, Col: 75} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 129, Col: 82}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"v-stat-value\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.MonthlyBudget > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div><div class=\"v-muted text-xs uppercase tracking-wide\">Monthly Budget</div><div class=\"font-mono text-2xl mt-1\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.0f", d.MonthlyBudget)) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.EbayToday))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 127, Col: 82} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 64}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.MonthlyBudget > 0 { if d.MonthlyBudget > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"mt-4\"><progress class=\"v-progress w-full\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div><div class=\"v-stat-label\">Monthly budget</div><div class=\"v-stat-value\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", d.BudgetPercent())) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.0f", d.MonthlyBudget))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 133, Col: 85} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 137, Col: 71}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" max=\"100\"></progress><div class=\"flex justify-between text-xs mt-1\"><span class=\"v-muted\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.MonthlyBudget > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"mt-6 max-w-md\"><progress class=\"v-progress w-full\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d%% of budget used", d.BudgetPercent())) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", d.BudgetPercent()))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 135, Col: 83} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 143, Col: 85}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" max=\"100\"></progress><div class=\"flex justify-between text-xs mt-2\"><span class=\"v-muted\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.BudgetOver() { var templ_7745c5c3_Var15 string
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span class=\"v-price-target\">Over budget</span>") templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d%% of budget used", d.BudgetPercent()))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 145, Col: 83}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(d.RecentResults) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"v-muted text-sm\">No results yet.</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<table class=\"v-table\"><thead><tr><th>Item</th><th>Price</th><th>Source</th><th>Found</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, r := range d.RecentResults {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<tr><td><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 templ.SafeURL
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 156, Col: 80}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string if d.BudgetOver() {
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<span class=\"v-price-target\">Over budget</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 156, Col: 95} return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(d.RecentResults) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"v-empty\">Nothing found yet. Veola is watching.</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<ul class=\"v-feed\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, r := range d.RecentResults {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<li class=\"v-feed-row\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 templ.SafeURL
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 163, Col: 75}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</a></td><td class=\"font-mono\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 string templ_7745c5c3_Err = thumb(r.ImageURL, r.Source).Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 157, Col: 62} return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</a><div class=\"min-w-0 flex-1\"><a class=\"v-feed-name\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 templ.SafeURL
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 167, Col: 96}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</td><td>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 158, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 167, Col: 111}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</td><td class=\"v-muted text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</a><div class=\"v-feed-meta truncate\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt)) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(resultMeta(r))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 159, Col: 59} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 168, Col: 58}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</td></tr>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div></div><div class=\"font-mono v-feed-price\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</tbody></table>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div><div class=\"v-card p-5\"><h2 class=\"font-semibold mb-3\">Recent Alerts</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(d.RecentAlerts) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"v-muted text-sm\">No alerts sent yet.</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<ul class=\"space-y-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, a := range d.RecentAlerts {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<li class=\"flex justify-between items-center border-b border-white/10 pb-2\"><span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(a.ItemName) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 174, Col: 26} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 170, Col: 75}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span> <span class=\"font-mono v-price-target\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</section><section><h2 class=\"v-section-title mb-4\">Recent alerts</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(d.RecentAlerts) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"v-empty\">No alerts sent. Nothing has hit target.</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<ul class=\"v-feed\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, a := range d.RecentAlerts {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<li class=\"v-feed-row\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = thumb(a.ImageURL, "").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<div class=\"min-w-0 flex-1\"><div class=\"v-feed-name truncate\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(a.Price, a.Currency)) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(a.ItemName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 175, Col: 78} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 186, Col: 55}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</span></li>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if a.Title != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<div class=\"v-feed-meta truncate\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(a.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 188, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</ul>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</div><div class=\"font-mono v-feed-price v-price-target\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(a.Price, a.Currency))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 191, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div></li>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</section></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -504,7 +551,10 @@ func DashboardBody(d DashboardData) templ.Component {
}) })
} }
func statCard(label, value, sub string) templ.Component { // 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.
func thumb(url, source string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -520,61 +570,114 @@ func statCard(label, value, sub string) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var22 := templ.GetChildren(ctx) templ_7745c5c3_Var24 := templ.GetChildren(ctx)
if templ_7745c5c3_Var22 == nil { if templ_7745c5c3_Var24 == nil {
templ_7745c5c3_Var22 = templ.NopComponent templ_7745c5c3_Var24 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div class=\"v-card p-4\"><div class=\"v-muted text-xs uppercase tracking-wide\">") if url != "" {
if templ_7745c5c3_Err != nil { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<img class=\"v-thumb\" src=\"")
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 187, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div><div class=\"font-mono text-3xl mt-1\" data-countup>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 188, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if sub != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"v-muted text-xs mt-1\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(sub) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(url)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 190, Col: 42} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 206, Col: 32}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" loading=\"lazy\" alt=\"\" referrerpolicy=\"no-referrer\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div class=\"v-thumb v-thumb-empty\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(sourceInitial(source))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 208, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div>") return nil
})
}
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])
}
func statBlock(label, value string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var27 := templ.GetChildren(ctx)
if templ_7745c5c3_Var27 == nil {
templ_7745c5c3_Var27 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<div><div class=\"v-stat-label\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 228, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</div><div class=\"v-stat-value\" data-countup>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 229, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -598,9 +701,9 @@ func Dashboard(d DashboardData) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var26 := templ.GetChildren(ctx) templ_7745c5c3_Var30 := templ.GetChildren(ctx)
if templ_7745c5c3_Var26 == nil { if templ_7745c5c3_Var30 == nil {
templ_7745c5c3_Var26 = templ.NopComponent templ_7745c5c3_Var30 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Layout(d.Page, DashboardBody(d)).Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Layout(d.Page, DashboardBody(d)).Render(ctx, templ_7745c5c3_Buffer)