Compare commits

..

3 Commits

Author SHA1 Message Date
prosolis
ea3577a45e Items-list sparklines, retro CSS, pinned tooling, deploy docs
- Bulk-load recent price points per item and render a sparkline in
  the items list (new LoadRecentPriceHistory query avoids N+1).
- Add retro.css visual layer and refreshed login/items/layout styling.
- Swap the logo from webp to avif.
- Pin htmx/Chart.js/Tailwind/templ versions in the Makefile with
  vendor / tools / update-deps targets; README documents the
  dependency-bump flow and the hardened systemd deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:10:56 -07:00
prosolis
0ec97afafb Fix price chart growing unbounded on item results page
Wrap the canvas in a fixed-height container so Chart.js's
responsive + maintainAspectRatio:false combo has a stable parent
to size against, instead of feeding back into itself each tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:09:49 -07:00
prosolis
edb732ee1f Auction end times, visual flair, and pre-launch cleanup
Auction handling:
- Capture itemEndDate from eBay Browse API and ending_date from ZenMarket
  (Yahoo JP); plumb through results.ends_at column. Permissive ZenMarket
  parser (multiple layouts, JST when offset missing).
- Per-row "Ends" countdown column + "Ending soon" banner on results pages,
  live-ticked by flair.js with urgent/critical tinting under 1h/5m.
- Backfill ends_at for known auctions when their URL reappears in a poll
  (dedup hit no longer drops the new end time).
- Hide ended auctions from result listings by default via
  ResultsQuery.ExcludeEnded; rows stay in the DB.

Visual flair:
- Glassy backdrop-blur v-cards with gradient-mask borders and hover-lift.
- htmx swap fade-in via transient .v-just-swapped class.
- Count-up animation on dashboard stats. All animations gated behind
  prefers-reduced-motion.

eBay condition + region filters (auctions-style scoping):
- items.condition and items.region columns; threaded through item form,
  CreateItem/UpdateItem, scheduler eBay plan input, and previewKey so
  cache invalidates when these change.
- ebay.SearchParams gains conditionIds and itemLocationCountry filters.

Run Now reload + countdown engine:
- Run Now now sets HX-Refresh: true (non-htmx fallback: 303 redirect) so
  the entire results view — best price, chart, badge, last polled —
  reflects the new poll, instead of swapping just one partial.

Pre-launch hardening (P1 set):
- auth.EqualizeLoginTiming on no-such-user branch.
- (*App).serverError centralizes 500s; replaces err.Error() leaks across
  results/settings/items/users/dashboard handlers.
- main.go server: ReadTimeout 30s / WriteTimeout 60s / IdleTimeout 120s
  alongside the existing ReadHeaderTimeout.
- noListFS wrapper blocks static directory listings.
- Credential fields in settings no longer render value=; blank submission
  preserves the saved value, with per-field "Saved in settings / Set in
  config.toml / Not set" status indicator.

Misc:
- -debug flag wires slog to LevelDebug; raw ZenMarket items logged for
  format diagnosis.
- /healthz public endpoint for reverse-proxy probes.
- deploy/veola.service systemd unit template (hardening flags, single
  ReadWritePaths=/var/lib/veola).
- handlers_test.go covers /healthz, setup-gate redirect, auth gate, and
  /login render with httptest + in-memory sqlite.
- best_price_currency on items; templates pick the right symbol per row.
- .gitignore now excludes *.log / veola-debug.log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:47:09 -07:00
48 changed files with 3433 additions and 1283 deletions

4
.gitignore vendored
View File

@@ -19,3 +19,7 @@ config.toml
*.swp *.swp
.idea/ .idea/
.vscode/ .vscode/
# Debug log output from `-debug` runs
veola-debug.log
*.log

View File

@@ -1,18 +1,28 @@
# Veola build. # Veola build.
# #
# Requires the `templ` CLI (go install github.com/a-h/templ/cmd/templ@latest). # Tool dependencies the `*_VERSION` variables below are the single source of
# The Tailwind standalone CLI is fetched on demand into bin/ (gitignored) — no # truth for every non-Go thing Veola pulls in at build time. To upgrade one,
# node toolchain required. static/css/tailwind.css is a committed build # bump its version and run `make vendor` (vendored JS) or just `make css`
# artifact so a plain `go build` deploy still has styles; run `make css` # (Tailwind) on a clean `bin/` to refetch.
# (or `make build`) after touching templates or static/css/input.css. #
# Go module dependencies live in go.mod and are bumped via `make update-deps`.
TAILWIND_VERSION := v3.4.17 TAILWIND_VERSION := v3.4.17
TAILWIND_BIN := bin/tailwindcss HTMX_VERSION := 2.0.4
# linux-x64 only; change the asset name for other platforms. CHARTJS_VERSION := 4.4.6
TAILWIND_URL := https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64 TEMPL_VERSION := v0.3.1020
TEMPL := $(shell go env GOPATH)/bin/templ
.PHONY: all generate css build run test clean TAILWIND_BIN := bin/tailwindcss
# Override TAILWIND_URL for non-linux-x64 platforms — see README "Build-time
# tools" for the matching asset names on the Tailwind releases page.
TAILWIND_URL ?= https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64
HTMX_URL := https://unpkg.com/htmx.org@$(HTMX_VERSION)/dist/htmx.min.js
CHARTJS_URL := https://cdn.jsdelivr.net/npm/chart.js@$(CHARTJS_VERSION)/dist/chart.umd.min.js
TEMPL := $(shell go env GOPATH)/bin/templ
.PHONY: all generate css build run test clean tools vendor update-deps
all: build all: build
@@ -41,3 +51,32 @@ test:
clean: clean:
rm -f veola-bin rm -f veola-bin
# Install the pinned templ CLI into $(go env GOPATH)/bin. Pinning matters
# because two contributors on different templ versions can produce
# differently-formatted generated Go for the same .templ source.
tools:
go install github.com/a-h/templ/cmd/templ@$(TEMPL_VERSION)
# Re-fetch vendored third-party JS at the currently pinned versions,
# overwriting whatever is on disk. Also clears bin/tailwindcss so the next
# `make css` pulls the pinned Tailwind binary. Use after bumping any of
# HTMX_VERSION / CHARTJS_VERSION / TAILWIND_VERSION.
vendor:
curl -sL --fail $(HTMX_URL) -o static/vendor/htmx.min.js
curl -sL --fail $(CHARTJS_URL) -o static/vendor/chart.umd.min.js
rm -f $(TAILWIND_BIN)
$(MAKE) $(TAILWIND_BIN)
# Bump Go module dependencies to their newest compatible versions, then
# print upstream-release URLs for the non-Go tools so you can decide
# whether to bump those pins too. Does not modify the pinned versions.
update-deps:
go get -u ./...
go mod tidy
@echo
@echo "Pinned tool versions (current → upstream releases):"
@printf " Tailwind %s → https://github.com/tailwindlabs/tailwindcss/releases/latest\n" "$(TAILWIND_VERSION)"
@printf " htmx %s → https://github.com/bigskysoftware/htmx/releases/latest\n" "$(HTMX_VERSION)"
@printf " Chart.js %s → https://github.com/chartjs/Chart.js/releases/latest\n" "$(CHARTJS_VERSION)"
@printf " templ %s → https://github.com/a-h/templ/releases/latest\n" "$(TEMPL_VERSION)"

111
README.md
View File

@@ -18,18 +18,68 @@ See [`veola-spec.md`](veola-spec.md) for the full specification.
## Requirements ## Requirements
- Go 1.22+ (developed against 1.25) - Go 1.22+ (developed against 1.25)
- `make`, `curl`, and a POSIX shell (for the Makefile)
- A reachable [ntfy](https://ntfy.sh) instance (self-hosted or ntfy.sh) - A reachable [ntfy](https://ntfy.sh) instance (self-hosted or ntfy.sh)
- An [eBay developer](https://developer.ebay.com) keyset (App ID + Cert ID) — for eBay marketplaces - An [eBay developer](https://developer.ebay.com) keyset (App ID + Cert ID) — for eBay marketplaces
- An [Apify](https://apify.com) account + API key — for the non-eBay marketplaces - An [Apify](https://apify.com) account + API key — for the non-eBay marketplaces
- To build from source: the [`templ`](https://templ.guide) CLI. The Tailwind standalone CLI is fetched automatically by the Makefile — no Node toolchain required.
### Build-time tools
All non-Go tool versions are pinned in the Makefile at the top:
```make
TAILWIND_VERSION := v3.4.17
HTMX_VERSION := 2.0.4
CHARTJS_VERSION := 4.4.6
TEMPL_VERSION := v0.3.1020
```
Install `templ` yourself at the pinned version; Tailwind is downloaded for you on first `make css`. htmx and Chart.js are already vendored in `static/vendor/` at the pinned versions above and committed to the repo.
```sh
# Install the pinned templ CLI.
make tools
# (or, equivalently)
go install github.com/a-h/templ/cmd/templ@v0.3.1020
# Confirm it's on PATH (typically $(go env GOPATH)/bin).
templ --version
```
You **do not need Node, npm, or Yarn**. Tailwind ships as a self-contained binary — the Makefile fetches the [standalone Tailwind CLI](https://github.com/tailwindlabs/tailwindcss/releases) into `./bin/tailwindcss` on first `make css`. The `bin/` directory is gitignored.
By default the Makefile downloads the **linux-x64** build. For other platforms, override `TAILWIND_URL` on the command line — pick the matching asset from the [Tailwind releases page](https://github.com/tailwindlabs/tailwindcss/releases/tag/v3.4.17):
```sh
# macOS Apple Silicon
make css TAILWIND_URL=https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-macos-arm64
# macOS Intel
make css TAILWIND_URL=https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-macos-x64
# linux-arm64
make css TAILWIND_URL=https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-linux-arm64
```
Or drop a binary at `./bin/tailwindcss` yourself and the Makefile will use it.
## Build ## Build
First time:
```sh
go install github.com/a-h/templ/cmd/templ@latest # if not already
make build # fetches Tailwind, runs templ, compiles
```
Subsequent builds:
```sh ```sh
make build make build
``` ```
This runs `templ generate`, compiles Tailwind, and produces `veola-bin`. Makefile targets: This runs `templ generate`, compiles Tailwind utilities into `static/css/tailwind.css`, and produces `veola-bin`. Makefile targets:
| Target | What it does | | Target | What it does |
| --- | --- | | --- | --- |
@@ -38,10 +88,11 @@ This runs `templ generate`, compiles Tailwind, and produces `veola-bin`. Makefil
| `make build` | `generate` + `css` + `go build -o veola-bin .` | | `make build` | `generate` + `css` + `go build -o veola-bin .` |
| `make run` | `build`, then run against `config.toml` | | `make run` | `build`, then run against `config.toml` |
| `make test` | `go test ./...` | | `make test` | `go test ./...` |
| `make clean` | Remove `veola-bin` |
The binary is named `veola-bin` rather than `veola` because the module is also `veola``go build` cannot write a binary with the same name as the module dir. The binary is named `veola-bin` rather than `veola` because the module is also `veola``go build` cannot write a binary with the same name as the module dir.
`static/css/tailwind.css` is committed, so a deploy box can `go build -o veola-bin .` without the Tailwind CLI. Re-run `make css` whenever you change templates or `static/css/input.css`. The hand-written component layer in `static/css/app.css` is loaded separately and needs no rebuild. `static/css/tailwind.css` is committed, so a deploy box can `go build -o veola-bin .` without the Tailwind CLI or even `make`. Re-run `make css` whenever you change templates or `static/css/input.css`. The hand-written component layer in `static/css/app.css` is loaded separately and needs no rebuild.
## Configure ## Configure
@@ -70,13 +121,22 @@ Other notable config:
./veola-bin -config config.toml ./veola-bin -config config.toml
``` ```
CLI flags:
| Flag | Default | Notes |
| --- | --- | --- |
| `-config <path>` | `config.toml` | Path to the TOML config file |
| `-debug` | off | Verbose `log/slog` at `LevelDebug`. Logs raw external API payloads (eBay / ZenMarket / etc.) — useful when diagnosing parse failures. Not for production. |
First-run flow: First-run flow:
1. Visit `http://localhost:8080/`. With no users, you are redirected to `/setup`. 1. Visit `http://localhost:8080/`. With no users, you are redirected to `/setup`.
2. Create the admin account. 2. Create the admin account. The first user is always an admin.
3. Log in at `/login`. 3. Log in at `/login`.
4. Add items at `/items/new`. Optionally fill in your eBay/Apify credentials and ntfy URL via `/settings` if you didn't put them in `config.toml`. The Settings page also shows the running eBay API call count for the day. 4. Add items at `/items/new`. Optionally fill in your eBay/Apify credentials and ntfy URL via `/settings` if you didn't put them in `config.toml`. The Settings page also shows the running eBay API call count for the day.
Account registration is admin-only — there is no public signup. Once at least one user exists, `/setup` returns 404. New users are created from the Settings page by an admin.
The scheduler starts with the server and polls each active item on its configured interval. The bottom-of-hour global poll runs every `scheduler.global_poll_interval_minutes`. The scheduler starts with the server and polls each active item on its configured interval. The bottom-of-hour global poll runs every `scheduler.global_poll_interval_minutes`.
## Layout ## Layout
@@ -108,6 +168,30 @@ go test ./...
Unit tests cover crypto round-trip, db round-trip and dedup, scheduler alert/badge logic, and eBay marketplace/filter mapping. No handler-level tests yet. Unit tests cover crypto round-trip, db round-trip and dedup, scheduler alert/badge logic, and eBay marketplace/filter mapping. No handler-level tests yet.
## Keeping dependencies current
Veola pulls from four sources, all version-pinned for reproducibility.
```sh
# Bump Go module deps to their newest compatible versions, then prints
# upstream-release URLs for the four pinned tools so you can spot bumps.
make update-deps
# After bumping any of TAILWIND_VERSION / HTMX_VERSION / CHARTJS_VERSION
# in the Makefile, refetch the vendored assets at the new pins:
make vendor
# After bumping TEMPL_VERSION:
make tools
# Then rebuild and run the test suite:
make build && make test
```
The pinned tool versions live at the top of the Makefile. The vendored JS at `static/vendor/htmx.min.js` and `static/vendor/chart.umd.min.js` is committed and updated only by `make vendor`. Tailwind v3.4.17 is intentionally pinned — v4 is a breaking release that drops the `tailwind.config.js` format Veola uses.
For automated tracking, point [Dependabot](https://docs.github.com/en/code-security/dependabot) or [Renovate](https://docs.renovatebot.com/) at the repo. Both can watch `go.mod` natively; Renovate's custom-regex managers can also track the `*_VERSION` lines in the Makefile.
## Operate ## Operate
- The SQLite file lives at `server.db_path` (default `./veola.db`). Back this up — it holds your watched items, history, encrypted secrets, and user accounts. - The SQLite file lives at `server.db_path` (default `./veola.db`). Back this up — it holds your watched items, history, encrypted secrets, and user accounts.
@@ -122,6 +206,25 @@ Veola speaks plain HTTP and is meant to sit behind a TLS-terminating reverse pro
- Keep `server.secure_cookies = true` (the default). - Keep `server.secure_cookies = true` (the default).
- Terminate TLS at the proxy and set HSTS there — Veola does not emit HSTS itself. - Terminate TLS at the proxy and set HSTS there — Veola does not emit HSTS itself.
- Veola sets `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, and `Referrer-Policy` on every response, and trusts `X-Forwarded-For` for client IPs — configure the proxy to strip client-supplied `X-Forwarded-*` headers so they cannot be spoofed. - Veola sets `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, and `Referrer-Policy` on every response, and trusts `X-Forwarded-For` for client IPs — configure the proxy to strip client-supplied `X-Forwarded-*` headers so they cannot be spoofed.
- `GET /healthz` returns 200 `ok` with no auth — wire it up to your proxy/uptime probe.
A hardened systemd unit template lives at [`deploy/veola.service`](deploy/veola.service). It assumes:
- Binary at `/usr/local/bin/veola-bin`
- Config at `/etc/veola/config.toml`
- A `veola` system user with `/var/lib/veola` as its working / data directory (the only writable path under `ReadWritePaths`)
Install sketch:
```sh
sudo useradd --system --home /var/lib/veola --shell /usr/sbin/nologin veola
sudo install -d -o veola -g veola -m 0750 /var/lib/veola /etc/veola
sudo install -m 0755 veola-bin /usr/local/bin/veola-bin
sudo install -m 0640 -o veola -g veola config.toml /etc/veola/config.toml
sudo install -m 0644 deploy/veola.service /etc/systemd/system/veola.service
sudo systemctl daemon-reload
sudo systemctl enable --now veola
```
## Aesthetic ## Aesthetic

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

52
deploy/veola.service Normal file
View File

@@ -0,0 +1,52 @@
[Unit]
Description=Veola price tracker
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# --- Edit these for your host ---------------------------------------------
# User must be able to read config.toml and write WorkingDirectory (sqlite WAL).
User=veola
Group=veola
WorkingDirectory=/var/lib/veola
ExecStart=/usr/local/bin/veola-bin -config /etc/veola/config.toml
# --------------------------------------------------------------------------
Restart=on-failure
RestartSec=5s
# SIGINT triggers the graceful-shutdown path in main.go (matches Ctrl-C).
KillSignal=SIGINT
TimeoutStopSec=45s
# Hardening. Veola only needs to read its config, write its sqlite db, and
# reach the network. Everything else can be locked down.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
ProtectProc=invisible
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
CapabilityBoundingSet=
AmbientCapabilities=
# Allow writes only to the sqlite db directory.
ReadWritePaths=/var/lib/veola
UMask=0027
[Install]
WantedBy=multi-user.target

View File

@@ -2,10 +2,70 @@ package apify
import ( import (
"encoding/json" "encoding/json"
"log/slog"
"strconv" "strconv"
"strings" "strings"
"time"
) )
// jstLocation is the timezone ZenMarket reports Yahoo Auctions JP end times
// in when the value lacks an explicit offset. Falls back to UTC if the
// embedded tzdata lookup fails (main.go imports time/tzdata so in practice
// this always resolves).
var jstLocation = func() *time.Location {
loc, err := time.LoadLocation("Asia/Tokyo")
if err != nil {
return time.UTC
}
return loc
}()
// yahooEndingDateLayouts is the ordered list of layouts attempted when
// parsing ZenMarket's `ending_date`. Some entries use Local-as-JST (no
// offset in the string) — see parseYahooEndingDate for the routing.
var yahooEndingDateLayouts = []struct {
layout string
jst bool // true: parse as JST; false: parse with offset/zone from string
}{
{time.RFC3339, false},
{"2006-01-02T15:04:05", true},
{"2006-01-02 15:04:05", true},
{"2006/01/02 15:04:05", true},
{"2006/01/02 15:04", true},
{"2006-01-02 15:04", true},
}
// parseYahooEndingDate is a permissive parser for ZenMarket's ending_date
// field. The actor's output format has shifted over time and isn't documented;
// rather than wedging on one layout, try the known shapes in order and treat
// anything without an explicit zone as JST (Yahoo Auctions runs in Japan).
// Returns nil + logs a warning when nothing parses, so operators see the raw
// value and can extend this list.
func parseYahooEndingDate(s string) *time.Time {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
for _, l := range yahooEndingDateLayouts {
var (
t time.Time
err error
)
if l.jst {
t, err = time.ParseInLocation(l.layout, s, jstLocation)
} else {
t, err = time.Parse(l.layout, s)
}
if err == nil {
slog.Debug("yahoo ending_date parsed",
"raw", s, "layout", l.layout, "jst_assumed", l.jst, "parsed", t.UTC().Format(time.RFC3339))
return &t
}
}
slog.Warn("yahoo ending_date: no layout matched", "raw", s)
return nil
}
// ActiveListingInput is the input schema for `automation-lab/ebay-scraper`. // ActiveListingInput is the input schema for `automation-lab/ebay-scraper`.
// The actor accepts keyword searches and standard filters; it targets // The actor accepts keyword searches and standard filters; it targets
// ebay.com only (no per-marketplace routing in the actor itself), so // ebay.com only (no per-marketplace routing in the actor itself), so
@@ -145,6 +205,10 @@ type UnifiedResult struct {
// MatchedQuery records which alias from the item's query list produced // MatchedQuery records which alias from the item's query list produced
// this row. Empty for URL-only items or rows from non-search sources. // this row. Empty for URL-only items or rows from non-search sources.
MatchedQuery string MatchedQuery string
// EndsAt is the auction end time, if known. Auction-format eBay listings
// and Yahoo Auctions JP listings populate this; fixed-price listings
// and most Apify scraper outputs leave it nil.
EndsAt *time.Time
} }
// Decode unmarshals a list of raw JSON items into UnifiedResult slices using // Decode unmarshals a list of raw JSON items into UnifiedResult slices using
@@ -197,8 +261,10 @@ func Decode(items []json.RawMessage, source string) ([]UnifiedResult, error) {
} }
case SourceYahooJP: case SourceYahooJP:
for _, raw := range items { for _, raw := range items {
slog.Debug("yahoo raw item", "json", string(raw))
var r YahooAuctionsJPResult var r YahooAuctionsJPResult
if err := json.Unmarshal(raw, &r); err != nil { if err := json.Unmarshal(raw, &r); err != nil {
slog.Debug("yahoo decode failed", "err", err, "json", string(raw))
continue continue
} }
img := "" img := ""
@@ -213,6 +279,7 @@ func Decode(items []json.RawMessage, source string) ([]UnifiedResult, error) {
Store: "yahoo-auctions-jp (via zenmarket)", Store: "yahoo-auctions-jp (via zenmarket)",
ImageURL: img, ImageURL: img,
Source: source, Source: source,
EndsAt: parseYahooEndingDate(r.EndingDate),
}) })
} }
case SourceMercariJP: case SourceMercariJP:

View File

@@ -73,6 +73,19 @@ func CheckPassword(hash, plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
} }
// dummyHash is a valid bcrypt hash of a throwaway value. It exists only so a
// login attempt for a non-existent user can still run a real bcrypt
// comparison, equalizing response time and closing the user-enumeration
// timing oracle.
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("veola-timing-equalizer"), BcryptCost)
// EqualizeLoginTiming performs a bcrypt comparison against a throwaway hash so
// that a missing username costs roughly the same wall-clock time as a wrong
// password. Call it on the no-such-user branch of login.
func EqualizeLoginTiming() {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte("veola-timing-equalizer-x"))
}
// LogIn writes the user id into the session and rotates the token. // LogIn writes the user id into the session and rotates the token.
func (m *Manager) LogIn(ctx context.Context, userID int64) error { func (m *Manager) LogIn(ctx context.Context, userID int64) error {
if err := m.Sessions.RenewToken(ctx); err != nil { if err := m.Sessions.RenewToken(ctx); err != nil {

View File

@@ -37,6 +37,22 @@ func Open(path string) (*sql.DB, error) {
conn.Close() conn.Close()
return nil, err return nil, err
} }
if err := addColumnIfMissing(conn, "items", "condition", "TEXT"); err != nil {
conn.Close()
return nil, err
}
if err := addColumnIfMissing(conn, "items", "region", "TEXT"); err != nil {
conn.Close()
return nil, err
}
if err := addColumnIfMissing(conn, "items", "best_price_currency", "TEXT"); err != nil {
conn.Close()
return nil, err
}
if err := addColumnIfMissing(conn, "results", "ends_at", "DATETIME"); err != nil {
conn.Close()
return nil, err
}
return conn, nil return conn, nil
} }

View File

@@ -274,20 +274,20 @@ func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error)
INSERT INTO items ( INSERT INTO items (
name, search_query, url, category, target_price, ntfy_topic, ntfy_priority, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords, poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords,
listing_type, listing_type, condition, region,
actor_active, actor_sold, actor_price_compare, use_price_comparison, actor_active, actor_sold, actor_price_compare, use_price_comparison,
active, best_price, best_price_store, best_price_url, best_price_image_url, active, best_price, best_price_currency, best_price_store, best_price_url, best_price_image_url,
best_price_title, last_polled_at best_price_title, last_polled_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category), it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category),
nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority, nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority,
it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock), it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock),
nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)), nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)),
nullStr(it.ListingType), nullStr(it.ListingType), nullStr(it.Condition), nullStr(it.Region),
nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare), nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare),
boolToInt(it.UsePriceComparison), boolToInt(it.Active), boolToInt(it.UsePriceComparison), boolToInt(it.Active),
nullFloat(it.BestPrice), nullStr(it.BestPriceStore), nullFloat(it.BestPrice), nullStr(it.BestPriceCurrency), nullStr(it.BestPriceStore),
nullStr(s.enc(it.BestPriceURL)), nullStr(s.enc(it.BestPriceImageURL)), nullStr(s.enc(it.BestPriceURL)), nullStr(s.enc(it.BestPriceImageURL)),
nullStr(s.enc(it.BestPriceTitle)), nullTime(it.LastPolledAt), nullStr(s.enc(it.BestPriceTitle)), nullTime(it.LastPolledAt),
) )
@@ -310,7 +310,7 @@ func (s *Store) UpdateItem(ctx context.Context, it *models.Item) error {
name = ?, search_query = ?, url = ?, category = ?, target_price = ?, name = ?, search_query = ?, url = ?, category = ?, target_price = ?,
ntfy_topic = ?, ntfy_priority = ?, poll_interval_minutes = ?, ntfy_topic = ?, ntfy_priority = ?, poll_interval_minutes = ?,
include_out_of_stock = ?, min_price = ?, exclude_keywords = ?, include_out_of_stock = ?, min_price = ?, exclude_keywords = ?,
listing_type = ?, listing_type = ?, condition = ?, region = ?,
actor_active = ?, actor_sold = ?, actor_price_compare = ?, actor_active = ?, actor_sold = ?, actor_price_compare = ?,
use_price_comparison = ?, active = ?, updated_at = CURRENT_TIMESTAMP use_price_comparison = ?, active = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
@@ -319,7 +319,7 @@ func (s *Store) UpdateItem(ctx context.Context, it *models.Item) error {
nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority, nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority,
it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock), it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock),
nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)), nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)),
nullStr(it.ListingType), nullStr(it.ListingType), nullStr(it.Condition), nullStr(it.Region),
nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare), nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare),
boolToInt(it.UsePriceComparison), boolToInt(it.Active), boolToInt(it.UsePriceComparison), boolToInt(it.Active),
it.ID, it.ID,
@@ -491,11 +491,12 @@ func (s *Store) ListCategories(ctx context.Context) ([]string, error) {
// UpdateItemPollResult writes best-price fields, last_polled_at, last_poll_error. // UpdateItemPollResult writes best-price fields, last_polled_at, last_poll_error.
func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models.Item, errMsg string) error { func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models.Item, errMsg string) error {
var ( var (
bestPrice sql.NullFloat64 bestPrice sql.NullFloat64
bestStore, bestURL, bestImage, bestTitle, errField sql.NullString bestCurrency, bestStore, bestURL, bestImage, bestTitle, errField sql.NullString
) )
if best != nil { if best != nil {
bestPrice = nullFloat(best.BestPrice) bestPrice = nullFloat(best.BestPrice)
bestCurrency = nullStr(best.BestPriceCurrency)
bestStore = nullStr(best.BestPriceStore) bestStore = nullStr(best.BestPriceStore)
bestURL = nullStr(s.enc(best.BestPriceURL)) bestURL = nullStr(s.enc(best.BestPriceURL))
bestImage = nullStr(s.enc(best.BestPriceImageURL)) bestImage = nullStr(s.enc(best.BestPriceImageURL))
@@ -506,20 +507,20 @@ func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models
} }
_, err := s.DB.ExecContext(ctx, ` _, err := s.DB.ExecContext(ctx, `
UPDATE items SET UPDATE items SET
best_price = ?, best_price_store = ?, best_price_url = ?, best_price = ?, best_price_currency = ?, best_price_store = ?, best_price_url = ?,
best_price_image_url = ?, best_price_title = ?, best_price_image_url = ?, best_price_title = ?,
last_polled_at = CURRENT_TIMESTAMP, last_poll_error = ? last_polled_at = CURRENT_TIMESTAMP, last_poll_error = ?
WHERE id = ? WHERE id = ?
`, bestPrice, bestStore, bestURL, bestImage, bestTitle, errField, id) `, bestPrice, bestCurrency, bestStore, bestURL, bestImage, bestTitle, errField, id)
return err return err
} }
const itemSelect = ` const itemSelect = `
SELECT id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority, SELECT id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords, poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords,
listing_type, listing_type, condition, region,
actor_active, actor_sold, actor_price_compare, use_price_comparison, actor_active, actor_sold, actor_price_compare, use_price_comparison,
active, last_polled_at, last_poll_error, best_price, best_price_store, active, last_polled_at, last_poll_error, best_price, best_price_currency, best_price_store,
best_price_url, best_price_image_url, best_price_title, created_at, updated_at best_price_url, best_price_image_url, best_price_title, created_at, updated_at
FROM items FROM items
` `
@@ -532,10 +533,10 @@ func scanItem(r rowScanner) (*models.Item, error) {
var ( var (
it models.Item it models.Item
searchQuery, urlS, category, listingType sql.NullString searchQuery, urlS, category, listingType sql.NullString
excludeKw sql.NullString excludeKw, condition, region sql.NullString
actorA, actorS, actorP sql.NullString actorA, actorS, actorP sql.NullString
ntfyTopic, lastPollErr sql.NullString ntfyTopic, lastPollErr sql.NullString
bestStore, bestURL, bestImage, bestTitle sql.NullString bestCurrency, bestStore, bestURL, bestImage, bestTitle sql.NullString
targetPrice, minPrice, bestPrice sql.NullFloat64 targetPrice, minPrice, bestPrice sql.NullFloat64
includeOOS, usePC, active int includeOOS, usePC, active int
lastPolledAt sql.NullTime lastPolledAt sql.NullTime
@@ -543,9 +544,9 @@ func scanItem(r rowScanner) (*models.Item, error) {
if err := r.Scan( if err := r.Scan(
&it.ID, &it.Name, &searchQuery, &urlS, &category, &targetPrice, &ntfyTopic, &it.NtfyPriority, &it.ID, &it.Name, &searchQuery, &urlS, &category, &targetPrice, &ntfyTopic, &it.NtfyPriority,
&it.PollIntervalMinutes, &includeOOS, &minPrice, &excludeKw, &it.PollIntervalMinutes, &includeOOS, &minPrice, &excludeKw,
&listingType, &listingType, &condition, &region,
&actorA, &actorS, &actorP, &usePC, &actorA, &actorS, &actorP, &usePC,
&active, &lastPolledAt, &lastPollErr, &bestPrice, &bestStore, &active, &lastPolledAt, &lastPollErr, &bestPrice, &bestCurrency, &bestStore,
&bestURL, &bestImage, &bestTitle, &it.CreatedAt, &it.UpdatedAt, &bestURL, &bestImage, &bestTitle, &it.CreatedAt, &it.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -556,11 +557,14 @@ func scanItem(r rowScanner) (*models.Item, error) {
it.URL = urlS.String it.URL = urlS.String
it.Category = category.String it.Category = category.String
it.ListingType = listingType.String it.ListingType = listingType.String
it.Condition = condition.String
it.Region = region.String
it.ActorActive = actorA.String it.ActorActive = actorA.String
it.ActorSold = actorS.String it.ActorSold = actorS.String
it.ActorPriceCompare = actorP.String it.ActorPriceCompare = actorP.String
it.NtfyTopic = ntfyTopic.String it.NtfyTopic = ntfyTopic.String
it.LastPollError = lastPollErr.String it.LastPollError = lastPollErr.String
it.BestPriceCurrency = bestCurrency.String
it.BestPriceStore = bestStore.String it.BestPriceStore = bestStore.String
it.BestPriceURL = bestURL.String it.BestPriceURL = bestURL.String
it.BestPriceImageURL = bestImage.String it.BestPriceImageURL = bestImage.String
@@ -589,13 +593,14 @@ func (s *Store) decryptItem(it *models.Item) *models.Item {
func (s *Store) InsertResult(ctx context.Context, r *models.Result) (int64, error) { func (s *Store) InsertResult(ctx context.Context, r *models.Result) (int64, error) {
res, err := s.DB.ExecContext(ctx, ` res, err := s.DB.ExecContext(ctx, `
INSERT INTO results (item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at) INSERT INTO results (item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at, ends_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
`, `,
r.ItemID, s.enc(r.Title), nullFloat(r.Price), r.Currency, r.ItemID, s.enc(r.Title), nullFloat(r.Price), r.Currency,
nullStr(r.URL), nullStr(r.Source), s.enc(r.ImageURL), nullStr(r.URL), nullStr(r.Source), s.enc(r.ImageURL),
nullStr(s.enc(r.MatchedQuery)), nullStr(s.enc(r.MatchedQuery)),
boolToInt(r.Alerted), boolToInt(r.Alerted),
nullTime(r.EndsAt),
) )
if err != nil { if err != nil {
return 0, err return 0, err
@@ -624,11 +629,28 @@ func (s *Store) MarkResultAlerted(ctx context.Context, id int64) error {
return err return err
} }
// BackfillResultEndsAt sets ends_at on an existing result row that's currently
// missing one. Used during polling: when a known auction listing reappears in
// a poll result, we still want its end time recorded even though the row
// itself isn't being re-inserted (URL dedup).
func (s *Store) BackfillResultEndsAt(ctx context.Context, itemID int64, urlStr string, endsAt time.Time) error {
if urlStr == "" {
return nil
}
_, err := s.DB.ExecContext(ctx,
`UPDATE results SET ends_at = ? WHERE item_id = ? AND url = ? AND ends_at IS NULL`,
endsAt, itemID, urlStr)
return err
}
type ResultsQuery struct { type ResultsQuery struct {
ItemID int64 // 0 = all items ItemID int64 // 0 = all items
Limit int Limit int
Offset int Offset int
Order string // "price_asc", "price_desc", "found_desc" (default), "found_asc" Order string // "price_asc", "price_desc", "found_desc" (default), "found_asc"
// ExcludeEnded drops rows whose ends_at is in the past. Fixed-price
// listings (ends_at IS NULL) are kept regardless: they don't expire.
ExcludeEnded 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) {
@@ -646,14 +668,22 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul
limit = 20 limit = 20
} }
args := []any{} args := []any{}
where := "" var conds []string
if q.ItemID != 0 { if q.ItemID != 0 {
where = `WHERE item_id = ?` conds = append(conds, `item_id = ?`)
args = append(args, q.ItemID) args = append(args, q.ItemID)
} }
if q.ExcludeEnded {
conds = append(conds, `(ends_at IS NULL OR ends_at > ?)`)
args = append(args, time.Now().UTC())
}
where := ""
if len(conds) > 0 {
where = `WHERE ` + strings.Join(conds, ` AND `)
}
args = append(args, limit, q.Offset) args = append(args, limit, q.Offset)
rows, err := s.DB.QueryContext(ctx, fmt.Sprintf(` rows, err := s.DB.QueryContext(ctx, fmt.Sprintf(`
SELECT id, item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at SELECT id, item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at, ends_at
FROM results %s ORDER BY %s LIMIT ? OFFSET ? FROM results %s ORDER BY %s LIMIT ? OFFSET ?
`, where, order), args...) `, where, order), args...)
if err != nil { if err != nil {
@@ -667,8 +697,9 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul
title, urlS, source, imageS, matchQ sql.NullString title, urlS, source, imageS, matchQ sql.NullString
price sql.NullFloat64 price sql.NullFloat64
alerted int alerted int
endsAt sql.NullTime
) )
if err := rows.Scan(&r.ID, &r.ItemID, &title, &price, &r.Currency, &urlS, &source, &imageS, &matchQ, &alerted, &r.FoundAt); err != nil { if err := rows.Scan(&r.ID, &r.ItemID, &title, &price, &r.Currency, &urlS, &source, &imageS, &matchQ, &alerted, &r.FoundAt, &endsAt); err != nil {
return nil, err return nil, err
} }
r.Title = s.dec(title.String) r.Title = s.dec(title.String)
@@ -678,19 +709,76 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul
r.MatchedQuery = s.dec(matchQ.String) r.MatchedQuery = s.dec(matchQ.String)
r.Price = ptrFloat(price) r.Price = ptrFloat(price)
r.Alerted = alerted != 0 r.Alerted = alerted != 0
r.EndsAt = ptrTime(endsAt)
out = append(out, r) out = append(out, r)
} }
return out, rows.Err() return out, rows.Err()
} }
func (s *Store) CountResults(ctx context.Context, itemID int64) (int, error) { // EndingSoon is a compact projection for the "ending soon" strip: the single
// nearest-to-end auction across the user's results, with enough context to
// render and link to it.
type EndingSoon struct {
ItemID int64
ItemName string
Title string
URL string
EndsAt time.Time
}
// NextEndingResult returns the soonest-ending result whose ends_at lies in the
// window (now, now+within]. If itemID is 0 the search spans all items; nil is
// returned when no auction falls inside the window.
func (s *Store) NextEndingResult(ctx context.Context, itemID int64, within time.Duration) (*EndingSoon, error) {
now := time.Now().UTC()
cutoff := now.Add(within)
q := `SELECT r.item_id, r.title, r.url, r.ends_at, i.name
FROM results r JOIN items i ON r.item_id = i.id
WHERE r.ends_at IS NOT NULL AND r.ends_at > ? AND r.ends_at <= ?`
args := []any{now, cutoff}
if itemID != 0 {
q += ` AND r.item_id = ?`
args = append(args, itemID)
}
q += ` ORDER BY r.ends_at ASC LIMIT 1`
row := s.DB.QueryRowContext(ctx, q, args...)
var (
e EndingSoon
title sql.NullString
urlS sql.NullString
endsAt time.Time
)
if err := row.Scan(&e.ItemID, &title, &urlS, &endsAt, &e.ItemName); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
e.Title = s.dec(title.String)
e.URL = urlS.String
e.EndsAt = endsAt
return &e, nil
}
// CountResults returns the row count matching the same filters ListResults
// applies. Pagination relies on this matching the visible list, so it must
// honor ExcludeEnded too.
func (s *Store) CountResults(ctx context.Context, itemID int64, excludeEnded bool) (int, error) {
var n int var n int
q := `SELECT COUNT(*) FROM results` q := `SELECT COUNT(*) FROM results`
args := []any{} args := []any{}
var conds []string
if itemID != 0 { if itemID != 0 {
q += ` WHERE item_id = ?` conds = append(conds, `item_id = ?`)
args = append(args, itemID) args = append(args, itemID)
} }
if excludeEnded {
conds = append(conds, `(ends_at IS NULL OR ends_at > ?)`)
args = append(args, time.Now().UTC())
}
if len(conds) > 0 {
q += ` WHERE ` + strings.Join(conds, ` AND `)
}
err := s.DB.QueryRowContext(ctx, q, args...).Scan(&n) err := s.DB.QueryRowContext(ctx, q, args...).Scan(&n)
return n, err return n, err
} }
@@ -710,6 +798,49 @@ func (s *Store) InsertPricePoint(ctx context.Context, p *models.PricePoint) erro
return err return err
} }
// LoadRecentPriceHistory bulk-loads the last `perItem` price points (oldest →
// newest) for each item id. Returns a map keyed by item id; items with no
// history are absent from the map. Used by the items list page to render a
// sparkline per row without N+1 queries.
func (s *Store) LoadRecentPriceHistory(ctx context.Context, ids []int64, perItem int) (map[int64][]models.PricePoint, error) {
out := make(map[int64][]models.PricePoint, len(ids))
if len(ids) == 0 || perItem <= 0 {
return out, nil
}
placeholders := make([]string, len(ids))
args := make([]any, 0, len(ids)+1)
for i, id := range ids {
placeholders[i] = "?"
args = append(args, id)
}
args = append(args, perItem)
q := `
WITH ranked AS (
SELECT item_id, price, store, polled_at,
ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY polled_at DESC) AS rn
FROM price_history
WHERE item_id IN (` + strings.Join(placeholders, ",") + `)
)
SELECT item_id, price, store, polled_at FROM ranked WHERE rn <= ?
ORDER BY item_id, polled_at ASC
`
rows, err := s.DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var p models.PricePoint
var store sql.NullString
if err := rows.Scan(&p.ItemID, &p.Price, &store, &p.PolledAt); err != nil {
return nil, err
}
p.Store = s.dec(store.String)
out[p.ItemID] = append(out[p.ItemID], p)
}
return out, rows.Err()
}
func (s *Store) ListPriceHistory(ctx context.Context, itemID int64) ([]models.PricePoint, error) { func (s *Store) ListPriceHistory(ctx context.Context, itemID int64) ([]models.PricePoint, error) {
rows, err := s.DB.QueryContext(ctx, rows, err := s.DB.QueryContext(ctx,
`SELECT id, item_id, price, store, polled_at FROM price_history WHERE item_id = ? ORDER BY polled_at ASC`, `SELECT id, item_id, price, store, polled_at FROM price_history WHERE item_id = ? ORDER BY polled_at ASC`,

View File

@@ -23,6 +23,8 @@ CREATE TABLE IF NOT EXISTS items (
min_price REAL, min_price REAL,
exclude_keywords TEXT, exclude_keywords TEXT,
listing_type TEXT, listing_type TEXT,
condition TEXT,
region TEXT,
actor_active TEXT, actor_active TEXT,
actor_sold TEXT, actor_sold TEXT,
actor_price_compare TEXT, actor_price_compare TEXT,
@@ -31,6 +33,7 @@ CREATE TABLE IF NOT EXISTS items (
last_polled_at DATETIME, last_polled_at DATETIME,
last_poll_error TEXT, last_poll_error TEXT,
best_price REAL, best_price REAL,
best_price_currency TEXT,
best_price_store TEXT, best_price_store TEXT,
best_price_url TEXT, best_price_url TEXT,
best_price_image_url TEXT, best_price_image_url TEXT,
@@ -61,7 +64,8 @@ CREATE TABLE IF NOT EXISTS results (
image_url TEXT, image_url TEXT,
matched_query TEXT, matched_query TEXT,
alerted INTEGER DEFAULT 0, alerted INTEGER DEFAULT 0,
found_at DATETIME DEFAULT CURRENT_TIMESTAMP found_at DATETIME DEFAULT CURRENT_TIMESTAMP,
ends_at DATETIME
); );
CREATE INDEX IF NOT EXISTS idx_results_item ON results(item_id, found_at DESC); CREATE INDEX IF NOT EXISTS idx_results_item ON results(item_id, found_at DESC);

View File

@@ -149,6 +149,8 @@ type browseItemSummary struct {
Seller struct { Seller struct {
Username string `json:"username"` Username string `json:"username"`
} `json:"seller"` } `json:"seller"`
// itemEndDate is present only on auction-format listings.
ItemEndDate string `json:"itemEndDate"`
} }
// Search runs one item_summary/search call and returns normalized listings. // Search runs one item_summary/search call and returns normalized listings.
@@ -174,8 +176,20 @@ func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error)
q := url.Values{} q := url.Values{}
q.Set("q", query) q.Set("q", query)
q.Set("limit", strconv.Itoa(limit)) q.Set("limit", strconv.Itoa(limit))
// The Browse API "filter" parameter takes a comma-separated list of
// filter clauses; assemble whichever ones the caller requested.
var filters []string
if f := buyingOptionsFilter(p.ListingType); f != "" { if f := buyingOptionsFilter(p.ListingType); f != "" {
q.Set("filter", f) filters = append(filters, f)
}
if f := conditionIDsFilter(p.Condition); f != "" {
filters = append(filters, f)
}
if f := itemLocationFilter(p.Region); f != "" {
filters = append(filters, f)
}
if len(filters) > 0 {
q.Set("filter", strings.Join(filters, ","))
} }
reqURL := c.ends.browse + "/item_summary/search?" + q.Encode() reqURL := c.ends.browse + "/item_summary/search?" + q.Encode()
@@ -214,6 +228,12 @@ func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error)
if s.Seller.Username != "" { if s.Seller.Username != "" {
store = "ebay (" + s.Seller.Username + ")" store = "ebay (" + s.Seller.Username + ")"
} }
var endsAt *time.Time
if s.ItemEndDate != "" {
if t, err := time.Parse(time.RFC3339, s.ItemEndDate); err == nil {
endsAt = &t
}
}
out = append(out, Listing{ out = append(out, Listing{
Title: s.Title, Title: s.Title,
Price: price, Price: price,
@@ -221,6 +241,7 @@ func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error)
URL: s.ItemWebURL, URL: s.ItemWebURL,
Store: store, Store: store,
ImageURL: img, ImageURL: img,
EndsAt: endsAt,
}) })
} }
return out, nil return out, nil

View File

@@ -43,3 +43,35 @@ func TestBuyingOptionsFilter(t *testing.T) {
} }
} }
} }
func TestConditionIDsFilter(t *testing.T) {
cases := map[string]string{
"": "",
"anything": "",
"new": "conditionIds:{1000|1500}",
"NEW": "conditionIds:{1000|1500}",
" used ": "conditionIds:{3000}",
"refurbished": "conditionIds:{2000|2010|2020|2030|2500}",
"parts": "conditionIds:{7000}",
}
for in, want := range cases {
if got := conditionIDsFilter(in); got != want {
t.Errorf("conditionIDsFilter(%q) = %q, want %q", in, got, want)
}
}
}
func TestItemLocationFilter(t *testing.T) {
cases := map[string]string{
"": "",
" ": "",
"us": "itemLocationCountry:US",
"GB": "itemLocationCountry:GB",
" jp ": "itemLocationCountry:JP",
}
for in, want := range cases {
if got := itemLocationFilter(in); got != want {
t.Errorf("itemLocationFilter(%q) = %q, want %q", in, got, want)
}
}
}

View File

@@ -1,6 +1,9 @@
package ebay package ebay
import "strings" import (
"strings"
"time"
)
// SearchParams is the input to a single Browse API item_summary/search call. // SearchParams is the input to a single Browse API item_summary/search call.
// It is provider-specific and is carried as the opaque input payload of a // It is provider-specific and is carried as the opaque input payload of a
@@ -14,6 +17,14 @@ type SearchParams struct {
// ListingType is Veola's vocabulary ("all", "bin"/"buy_it_now", // ListingType is Veola's vocabulary ("all", "bin"/"buy_it_now",
// "auction"); it is mapped to a buyingOptions filter. // "auction"); it is mapped to a buyingOptions filter.
ListingType string ListingType string
// Condition is Veola's condition vocabulary ("new", "used",
// "refurbished", "parts"); it is mapped to a conditionIds filter. Empty
// means no condition filter.
Condition string
// Region is an ISO 3166-1 alpha-2 country code constraining item
// location (mapped to the itemLocationCountry filter). Empty means no
// location filter.
Region string
// Limit caps the number of results requested (Browse API max is 200). // Limit caps the number of results requested (Browse API max is 200).
Limit int Limit int
} }
@@ -28,6 +39,10 @@ type Listing struct {
URL string URL string
Store string Store string
ImageURL string ImageURL string
// EndsAt is the auction end time as reported by the Browse API
// (itemEndDate). Nil for fixed-price ("Buy It Now") listings, which
// don't have one.
EndsAt *time.Time
} }
// MarketplaceID maps a Veola marketplace string (e.g. "ebay.com", // MarketplaceID maps a Veola marketplace string (e.g. "ebay.com",
@@ -92,3 +107,34 @@ func buyingOptionsFilter(listingType string) string {
return "" return ""
} }
} }
// conditionIDsFilter maps Veola's condition vocabulary to a Browse API
// "conditionIds" filter clause. Each Veola value expands to the set of eBay
// condition IDs that belong to it (e.g. "new" covers both brand-new and
// new-other). An empty or unknown value yields no filter.
func conditionIDsFilter(condition string) string {
var ids string
switch strings.ToLower(strings.TrimSpace(condition)) {
case "new":
ids = "1000|1500"
case "used":
ids = "3000"
case "refurbished":
ids = "2000|2010|2020|2030|2500"
case "parts":
ids = "7000"
default:
return ""
}
return "conditionIds:{" + ids + "}"
}
// itemLocationFilter maps an ISO 3166-1 alpha-2 country code to a Browse API
// "itemLocationCountry" filter clause. An empty value yields no filter.
func itemLocationFilter(region string) string {
r := strings.ToUpper(strings.TrimSpace(region))
if r == "" {
return ""
}
return "itemLocationCountry:" + r
}

View File

@@ -27,6 +27,11 @@ func (a *App) PostLogin(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.PostFormValue("username")) username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password") password := r.PostFormValue("password")
u, err := a.Store.GetUserByUsername(r.Context(), username) u, err := a.Store.GetUserByUsername(r.Context(), username)
if err != nil || u == nil {
// Run a bcrypt comparison anyway so a missing username takes the
// same time as a wrong password (no user-enumeration oracle).
auth.EqualizeLoginTiming()
}
if err != nil || u == nil || !auth.CheckPassword(u.PasswordHash, password) { if err != nil || u == nil || !auth.CheckPassword(u.PasswordHash, password) {
render(w, r, templates.Login(templates.LoginData{ render(w, r, templates.Login(templates.LoginData{
Page: a.page(r, "Sign in", ""), Page: a.page(r, "Sign in", ""),

View File

@@ -12,7 +12,7 @@ import (
func (a *App) GetDashboard(w http.ResponseWriter, r *http.Request) { func (a *App) GetDashboard(w http.ResponseWriter, r *http.Request) {
d, err := a.dashboardData(r) d, err := a.dashboardData(r)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) a.serverError(w, r, err)
return return
} }
render(w, r, templates.Dashboard(d)) render(w, r, templates.Dashboard(d))
@@ -21,7 +21,7 @@ func (a *App) GetDashboard(w http.ResponseWriter, r *http.Request) {
func (a *App) GetDashboardRefresh(w http.ResponseWriter, r *http.Request) { func (a *App) GetDashboardRefresh(w http.ResponseWriter, r *http.Request) {
d, err := a.dashboardData(r) d, err := a.dashboardData(r)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) a.serverError(w, r, err)
return return
} }
// Render ONLY the inner body. The hx-swap="outerHTML" on DashboardBody's // Render ONLY the inner body. The hx-swap="outerHTML" on DashboardBody's
@@ -36,7 +36,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}) results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{Limit: 20, ExcludeEnded: true})
if err != nil { if err != nil {
return templates.DashboardData{}, err return templates.DashboardData{}, err
} }

View File

@@ -7,6 +7,7 @@ import (
"context" "context"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"strconv" "strconv"
"time" "time"
@@ -52,9 +53,19 @@ func (a *App) Routes() http.Handler {
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(securityHeaders) r.Use(securityHeaders)
fs := http.FileServer(http.Dir("./static")) // noListFS denies directory requests, so http.FileServer can't render
// an index listing of static/ if an index.html is ever absent.
fs := http.FileServer(noListFS{http.Dir("./static")})
r.Handle("/static/*", http.StripPrefix("/static/", fs)) r.Handle("/static/*", http.StripPrefix("/static/", fs))
// Health check for reverse-proxy/uptime probes. No session, no setup
// gate, no auth — just a 200 to confirm the process is serving.
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
// All other routes pass through session loading + setup gate. // All other routes pass through session loading + setup gate.
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(a.Auth.Sessions.LoadAndSave) r.Use(a.Auth.Sessions.LoadAndSave)
@@ -169,6 +180,34 @@ func (a *App) page(r *http.Request, title, active string) templates.Page {
} }
} }
// noListFS wraps an http.FileSystem and refuses to open directories, which
// stops http.FileServer from emitting an auto-generated directory listing.
type noListFS struct{ fs http.FileSystem }
func (n noListFS) Open(name string) (http.File, error) {
f, err := n.fs.Open(name)
if err != nil {
return nil, err
}
info, err := f.Stat()
if err != nil {
f.Close()
return nil, err
}
if info.IsDir() {
f.Close()
return nil, os.ErrNotExist
}
return f, nil
}
// serverError logs the underlying error and returns a generic 500 to the
// client, so internal details (DB errors, file paths) never reach the browser.
func (a *App) serverError(w http.ResponseWriter, r *http.Request, err error) {
slog.Error("handler error", "path", r.URL.Path, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
func render(w http.ResponseWriter, r *http.Request, c templ.Component) { func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := c.Render(r.Context(), w); err != nil { if err := c.Render(r.Context(), w); err != nil {

View File

@@ -0,0 +1,115 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"veola/internal/apify"
"veola/internal/auth"
"veola/internal/config"
"veola/internal/crypto"
"veola/internal/db"
"veola/internal/models"
"veola/internal/ntfy"
"veola/internal/scheduler"
)
// newTestApp builds an App backed by a fresh sqlite db in t.TempDir(). The
// scheduler, apify, and ntfy clients are wired but unused by the routes we
// hit here. The returned http.Handler is App.Routes().
func newTestApp(t *testing.T) (*App, http.Handler) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
sqlDB, err := db.Open(dbPath)
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { sqlDB.Close() })
key, err := crypto.DeriveKey([]byte("test-encryption-key-32-bytes-min-aaaaaa"))
if err != nil {
t.Fatalf("DeriveKey: %v", err)
}
store := db.NewStore(sqlDB, key)
am, err := auth.NewManager(sqlDB, store, strings.Repeat("a", 32), false)
if err != nil {
t.Fatalf("auth.NewManager: %v", err)
}
cfg := &config.Config{}
ap := apify.New("")
nt := ntfy.New("")
sc := scheduler.New(cfg, store, ap, nt)
app := New(cfg, store, am, ap, nt, sc)
return app, app.Routes()
}
func TestHealthz(t *testing.T) {
_, h := newTestApp(t)
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Body.String(); got != "ok" {
t.Fatalf("body = %q, want %q", got, "ok")
}
}
func TestSetupGateRedirectsWhenNoUsers(t *testing.T) {
_, h := newTestApp(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/setup" {
t.Fatalf("Location = %q, want /setup", loc)
}
}
func TestRequireAuthRedirectsToLogin(t *testing.T) {
app, h := newTestApp(t)
hash, err := auth.HashPassword("a-long-enough-password")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if _, err := app.Store.CreateUser(context.Background(), "admin", hash, models.RoleAdmin); err != nil {
t.Fatalf("CreateUser: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/login" {
t.Fatalf("Location = %q, want /login", loc)
}
}
func TestLoginPageRenders(t *testing.T) {
app, h := newTestApp(t)
hash, _ := auth.HashPassword("a-long-enough-password")
if _, err := app.Store.CreateUser(context.Background(), "admin", hash, models.RoleAdmin); err != nil {
t.Fatalf("CreateUser: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/login", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "<form") {
t.Fatalf("body missing <form>")
}
}

View File

@@ -30,11 +30,22 @@ func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
} }
} }
cats, _ := a.Store.ListCategories(r.Context()) cats, _ := a.Store.ListCategories(r.Context())
// Bulk-load recent price history so each row can render a sparkline
// without N+1 queries. 20 points is enough for a meaningful trend line
// at 80px wide and stays cheap on the largest realistic watchlists.
ids := make([]int64, 0, len(items))
for _, it := range items {
ids = append(ids, it.ID)
}
history, _ := a.Store.LoadRecentPriceHistory(r.Context(), ids, 20)
render(w, r, templates.Items(templates.ItemsData{ render(w, r, templates.Items(templates.ItemsData{
Page: a.page(r, "Items", "items"), Page: a.page(r, "Items", "items"),
Items: items, Items: items,
Categories: cats, Categories: cats,
SelectedCategory: cat, SelectedCategory: cat,
PriceHistory: history,
})) }))
} }
@@ -93,6 +104,8 @@ func parseItemForm(r *http.Request) (models.Item, []string) {
} }
it.Marketplaces = collectMarketplaces(r.PostForm["marketplace"], r.PostFormValue("marketplace_custom")) it.Marketplaces = collectMarketplaces(r.PostForm["marketplace"], r.PostFormValue("marketplace_custom"))
it.ListingType = strings.TrimSpace(r.PostFormValue("listing_type")) it.ListingType = strings.TrimSpace(r.PostFormValue("listing_type"))
it.Condition = strings.TrimSpace(r.PostFormValue("condition"))
it.Region = strings.ToUpper(strings.TrimSpace(r.PostFormValue("region")))
it.ActorActive = strings.TrimSpace(r.PostFormValue("actor_active")) it.ActorActive = strings.TrimSpace(r.PostFormValue("actor_active"))
it.ActorSold = strings.TrimSpace(r.PostFormValue("actor_sold")) it.ActorSold = strings.TrimSpace(r.PostFormValue("actor_sold"))
it.ActorPriceCompare = strings.TrimSpace(r.PostFormValue("actor_price_compare")) it.ActorPriceCompare = strings.TrimSpace(r.PostFormValue("actor_price_compare"))
@@ -253,6 +266,8 @@ func (a *App) runPreview(ctx context.Context, it models.Item) ([]apify.UnifiedRe
Marketplace: previewMarket, Marketplace: previewMarket,
ListingType: it.ListingType, ListingType: it.ListingType,
ActorIDs: strings.Join(actorIDs, ","), ActorIDs: strings.Join(actorIDs, ","),
Condition: it.Condition,
Region: it.Region,
MaxResults: 30, MaxResults: 30,
} }
if cached, src, ok := a.Preview.Get(key); ok { if cached, src, ok := a.Preview.Get(key); ok {
@@ -304,6 +319,8 @@ func formValuesFromItem(it models.Item, r *http.Request) templates.FormValues {
IncludeOutOfStock: it.IncludeOutOfStock, IncludeOutOfStock: it.IncludeOutOfStock,
Marketplaces: it.Marketplaces, Marketplaces: it.Marketplaces,
ListingType: it.ListingType, ListingType: it.ListingType,
Condition: it.Condition,
Region: it.Region,
ActorActive: it.ActorActive, ActorActive: it.ActorActive,
ActorSold: it.ActorSold, ActorSold: it.ActorSold,
ActorPriceCompare: it.ActorPriceCompare, ActorPriceCompare: it.ActorPriceCompare,
@@ -319,7 +336,7 @@ func (a *App) PostCreateItem(w http.ResponseWriter, r *http.Request) {
} }
id, err := a.Store.CreateItem(r.Context(), &it) id, err := a.Store.CreateItem(r.Context(), &it)
if err != nil { if err != nil {
http.Error(w, "could not save item: "+err.Error(), http.StatusInternalServerError) a.serverError(w, r, err)
return return
} }
it.ID = id it.ID = id
@@ -361,7 +378,7 @@ func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) {
updated.ID = id updated.ID = id
updated.Active = existing.Active updated.Active = existing.Active
if err := a.Store.UpdateItem(r.Context(), &updated); err != nil { if err := a.Store.UpdateItem(r.Context(), &updated); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) a.serverError(w, r, err)
return return
} }
a.Scheduler.SyncItem(updated) a.Scheduler.SyncItem(updated)
@@ -377,17 +394,18 @@ func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) {
} }
it.Active = !it.Active it.Active = !it.Active
if err := a.Store.SetItemActive(r.Context(), id, it.Active); err != nil { if err := a.Store.SetItemActive(r.Context(), id, it.Active); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) a.serverError(w, r, err)
return return
} }
a.Scheduler.SyncItem(*it) a.Scheduler.SyncItem(*it)
render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context()))) hist, _ := a.Store.LoadRecentPriceHistory(r.Context(), []int64{id}, 20)
render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context()), hist[id]))
} }
func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) { func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) {
id := intParam(r, "id") id := intParam(r, "id")
if err := a.Store.DeleteItem(r.Context(), id); err != nil { if err := a.Store.DeleteItem(r.Context(), id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) a.serverError(w, r, err)
return return
} }
a.Scheduler.RemoveItem(id) a.Scheduler.RemoveItem(id)
@@ -410,31 +428,22 @@ func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) {
defer cancel() defer cancel()
a.Scheduler.RunPoll(ctx, *it) a.Scheduler.RunPoll(ctx, *it)
// RunPoll writes best price, last_polled_at, and last_poll_error; re-fetch // A partial swap (single row or just the results table) leaves the rest
// so the rendered partial shows the post-poll state. // of the page — best-price card, price chart, "last polled" time, badge —
fresh, err := a.Store.GetItem(r.Context(), id) // looking stale, so the run reads as a no-op. Tell htmx to do a full
if err != nil || fresh == nil { // reload so every derived view picks up the post-poll state.
http.Error(w, "could not reload item after run", http.StatusInternalServerError) if r.Header.Get("HX-Request") != "" {
w.Header().Set("HX-Refresh", "true")
w.WriteHeader(http.StatusNoContent)
return return
} }
// The results page asks for a refreshed listing table; the items list // Non-htmx fallback: redirect back to the originating page.
// asks for a refreshed row. Both POST to this same endpoint. target := "/items"
if r.PostFormValue("from") == "results" { if r.PostFormValue("from") == "results" {
d, err := a.buildItemResultsData(r, fresh, 1, "found_desc") target = fmt.Sprintf("/items/%d/results", id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if fresh.LastPollError != "" {
d.RunError = "Run finished with errors: " + fresh.LastPollError
} else {
d.RunMsg = fmt.Sprintf("Run complete. Showing %d listing(s).", len(d.Results))
}
render(w, r, templates.ItemResultsTable(d))
return
} }
render(w, r, templates.ItemRow(*fresh, a.Auth.CSRFToken(r.Context()))) http.Redirect(w, r, target, http.StatusSeeOther)
} }
func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) { func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) {

View File

@@ -10,8 +10,13 @@ import (
// previewKey caches the *raw* apify result set (post-decode, post-merge, // previewKey caches the *raw* apify result set (post-decode, post-merge,
// pre-filter). Filters like min_price and exclude_keywords are applied after // pre-filter). Filters like min_price and exclude_keywords are applied after
// the cache lookup so the operator can iterate on them without burning credits. // the cache lookup so the operator can iterate on them without burning credits.
//
// Condition and Region are part of the key, not post-filters: they are
// server-side eBay Browse API filters that change the result set the API
// returns, so a different condition/region must miss the cache.
type previewKey struct { type previewKey struct {
Queries, URL, Marketplace, ListingType, ActorIDs string Queries, URL, Marketplace, ListingType, ActorIDs string
Condition, Region string
MaxResults int MaxResults int
} }

View File

@@ -24,7 +24,7 @@ func (a *App) GetItemResults(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page")) page, _ := strconv.Atoi(r.URL.Query().Get("page"))
d, err := a.buildItemResultsData(r, it, page, r.URL.Query().Get("order")) d, err := a.buildItemResultsData(r, it, page, r.URL.Query().Get("order"))
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) a.serverError(w, r, err)
return return
} }
render(w, r, templates.ItemResults(d)) render(w, r, templates.ItemResults(d))
@@ -41,7 +41,7 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o
page = 1 page = 1
} }
total, err := a.Store.CountResults(r.Context(), it.ID) total, err := a.Store.CountResults(r.Context(), it.ID, true)
if err != nil { if err != nil {
return templates.ItemResultsData{}, err return templates.ItemResultsData{}, err
} }
@@ -54,10 +54,11 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o
} }
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{ results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
ItemID: it.ID, ItemID: it.ID,
Limit: resultsPerPage, Limit: resultsPerPage,
Offset: (page - 1) * resultsPerPage, Offset: (page - 1) * resultsPerPage,
Order: order, Order: order,
ExcludeEnded: true,
}) })
if err != nil { if err != nil {
return templates.ItemResultsData{}, err return templates.ItemResultsData{}, err
@@ -68,6 +69,10 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o
return templates.ItemResultsData{}, err return templates.ItemResultsData{}, err
} }
// 24h surface for the "ending soon" strip — beyond that, a static
// "ends in 4 days" in the per-row cell carries enough signal on its own.
endingSoon, _ := a.Store.NextEndingResult(r.Context(), it.ID, 24*time.Hour)
return templates.ItemResultsData{ return templates.ItemResultsData{
Page: a.page(r, it.Name, "items"), Page: a.page(r, it.Name, "items"),
Item: *it, Item: *it,
@@ -78,6 +83,7 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o
TotalPages: totalPages, TotalPages: totalPages,
Order: order, Order: order,
HistoryChartJSON: buildChartJSON(history), HistoryChartJSON: buildChartJSON(history),
EndingSoon: endingSoon,
}, nil }, nil
} }
@@ -101,7 +107,7 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
items, err := a.Store.ListItems(r.Context()) items, err := a.Store.ListItems(r.Context())
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) a.serverError(w, r, err)
return return
} }
names := make(map[int64]string, len(items)) names := make(map[int64]string, len(items))
@@ -110,11 +116,12 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
} }
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{ results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
ItemID: itemID, ItemID: itemID,
Limit: 200, Limit: 200,
ExcludeEnded: true,
}) })
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) a.serverError(w, r, err)
return return
} }
@@ -138,12 +145,15 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
}) })
} }
endingSoon, _ := a.Store.NextEndingResult(r.Context(), itemID, 24*time.Hour)
render(w, r, templates.GlobalResults(templates.GlobalResultsData{ render(w, r, templates.GlobalResults(templates.GlobalResultsData{
Page: a.page(r, "Results", "results"), Page: a.page(r, "Results", "results"),
Items: items, Items: items,
Results: rows, Results: rows,
ItemID: itemID, ItemID: itemID,
From: from, From: from,
To: to, To: to,
EndingSoon: endingSoon,
})) }))
} }

View File

@@ -26,6 +26,40 @@ var settingsKeys = []string{
"match_confidence_threshold", "match_confidence_threshold",
} }
// secretSettingsKeys are credential fields. Their values are never rendered
// back into the form, so a blank submission means "leave unchanged" rather
// than "clear" — see PostSettings.
var secretSettingsKeys = map[string]bool{
"apify_api_key": true,
"ebay_client_id": true,
"ebay_client_secret": true,
"ntfy_token": true,
}
// credentialStatus reports, per secret key, whether a value is saved in the
// settings table, inherited from config.toml, or absent — without exposing
// the secret itself.
func (a *App) credentialStatus(values map[string]string) map[string]string {
configVals := map[string]string{
"apify_api_key": a.Cfg.Apify.APIKey,
"ebay_client_id": a.Cfg.Ebay.ClientID,
"ebay_client_secret": a.Cfg.Ebay.ClientSecret,
"ntfy_token": "",
}
status := make(map[string]string, len(secretSettingsKeys))
for k := range secretSettingsKeys {
switch {
case strings.TrimSpace(values[k]) != "":
status[k] = "Saved in settings"
case strings.TrimSpace(configVals[k]) != "":
status[k] = "Set in config.toml"
default:
status[k] = "Not set"
}
}
return status
}
func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) { func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
values, err := a.Store.GetAllSettings(r.Context()) values, err := a.Store.GetAllSettings(r.Context())
if err != nil { if err != nil {
@@ -38,19 +72,20 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
cur := auth.CurrentUserFromRequest(r) cur := auth.CurrentUserFromRequest(r)
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context()) ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
return templates.SettingsData{ return templates.SettingsData{
Page: a.page(r, "Settings", "settings"), Page: a.page(r, "Settings", "settings"),
Values: values, Values: values,
IsAdmin: cur != nil && cur.Role == models.RoleAdmin, CredentialStatus: a.credentialStatus(values),
Users: users, IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
EbayUsedToday: ebayUsed, Users: users,
EbayDailyLimit: ebayLimit, EbayUsedToday: ebayUsed,
EbayDailyLimit: ebayLimit,
}, nil }, nil
} }
func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) { func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) {
d, err := a.settingsData(r) d, err := a.settingsData(r)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) a.serverError(w, r, err)
return return
} }
render(w, r, templates.Settings(d)) render(w, r, templates.Settings(d))
@@ -68,8 +103,14 @@ func (a *App) PostSettings(w http.ResponseWriter, r *http.Request) {
} }
for _, k := range settingsKeys { for _, k := range settingsKeys {
v := strings.TrimSpace(r.PostFormValue(k)) v := strings.TrimSpace(r.PostFormValue(k))
// Secret fields are never rendered back into the form, so a blank
// submission is the normal state and means "leave unchanged" — not
// "clear". (To clear a stored credential, edit the settings table.)
if v == "" && secretSettingsKeys[k] {
continue
}
if err := a.Store.SetSetting(r.Context(), k, v); err != nil { if err := a.Store.SetSetting(r.Context(), k, v); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) a.serverError(w, r, err)
return return
} }
} }
@@ -92,7 +133,7 @@ func (a *App) PostPasswordChange(w http.ResponseWriter, r *http.Request) {
d, err := a.settingsData(r) d, err := a.settingsData(r)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) a.serverError(w, r, err)
return return
} }
@@ -115,7 +156,7 @@ func (a *App) PostPasswordChange(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := a.Store.UpdateUserPassword(r.Context(), cur.ID, hash); err != nil { if err := a.Store.UpdateUserPassword(r.Context(), cur.ID, hash); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) a.serverError(w, r, err)
return return
} }
d.PasswordMsg = "Password updated" d.PasswordMsg = "Password updated"
@@ -130,7 +171,7 @@ func (a *App) PostTestNtfy(w http.ResponseWriter, r *http.Request) {
} }
d, err := a.settingsData(r) d, err := a.settingsData(r)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) a.serverError(w, r, err)
return return
} }
baseURL := strings.TrimSpace(d.Values["ntfy_base_url"]) baseURL := strings.TrimSpace(d.Values["ntfy_base_url"])
@@ -164,7 +205,7 @@ func (a *App) PostTestApify(w http.ResponseWriter, r *http.Request) {
} }
d, err := a.settingsData(r) d, err := a.settingsData(r)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) a.serverError(w, r, err)
return return
} }
apiKey := strings.TrimSpace(d.Values["apify_api_key"]) apiKey := strings.TrimSpace(d.Values["apify_api_key"])
@@ -210,7 +251,7 @@ func (a *App) PostTestEbay(w http.ResponseWriter, r *http.Request) {
} }
d, err := a.settingsData(r) d, err := a.settingsData(r)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) a.serverError(w, r, err)
return return
} }
// Settings-table values win over config.toml. Both paths are trimmed: // Settings-table values win over config.toml. Both paths are trimmed:

View File

@@ -13,7 +13,7 @@ import (
func (a *App) renderSettingsWithUserMsg(w http.ResponseWriter, r *http.Request, msg, errMsg string) { func (a *App) renderSettingsWithUserMsg(w http.ResponseWriter, r *http.Request, msg, errMsg string) {
d, err := a.settingsData(r) d, err := a.settingsData(r)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) a.serverError(w, r, err)
return return
} }
d.UserMsg = msg d.UserMsg = msg

View File

@@ -35,6 +35,12 @@ type Item struct {
ExcludeKeywords string ExcludeKeywords string
Marketplaces []string Marketplaces []string
ListingType string ListingType string
// Condition and Region are eBay-only search filters. Condition is
// Veola's vocabulary ("new", "used", "refurbished", "parts"); Region is
// an ISO 3166-1 alpha-2 country code constraining item location. Both
// empty means no filter, and both are ignored for non-eBay marketplaces.
Condition string
Region string
ActorActive string ActorActive string
ActorSold string ActorSold string
ActorPriceCompare string ActorPriceCompare string
@@ -43,6 +49,7 @@ type Item struct {
LastPolledAt *time.Time LastPolledAt *time.Time
LastPollError string LastPollError string
BestPrice *float64 BestPrice *float64
BestPriceCurrency string
BestPriceStore string BestPriceStore string
BestPriceURL string BestPriceURL string
BestPriceImageURL string BestPriceImageURL string
@@ -63,6 +70,10 @@ type Result struct {
MatchedQuery string MatchedQuery string
Alerted bool Alerted bool
FoundAt time.Time FoundAt time.Time
// EndsAt is populated for auction-format listings (eBay auctions,
// Yahoo Auctions JP). Nil for fixed-price listings and for any source
// that doesn't report an end time.
EndsAt *time.Time
} }
// SearchQueries returns the item's alias list. Splits on newline, comma, and // SearchQueries returns the item's alias list. Splits on newline, comma, and

View File

@@ -220,6 +220,14 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
continue continue
} }
if exists { if exists {
// Row already stored — but if this poll surfaced an end time we
// didn't have before (or the row predates the ends_at column),
// backfill it so countdowns light up for known auctions.
if r.EndsAt != nil {
if err := s.store.BackfillResultEndsAt(ctx, it.ID, r.URL, *r.EndsAt); err != nil {
slog.Error("backfill ends_at failed", "err", err)
}
}
continue continue
} }
alerted := false alerted := false
@@ -242,6 +250,7 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
ImageURL: r.ImageURL, ImageURL: r.ImageURL,
MatchedQuery: r.MatchedQuery, MatchedQuery: r.MatchedQuery,
Alerted: alerted, Alerted: alerted,
EndsAt: r.EndsAt,
}) })
if err != nil { if err != nil {
slog.Error("insert result failed", "err", err) slog.Error("insert result failed", "err", err)
@@ -257,6 +266,7 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
bp := best.Price bp := best.Price
_ = s.store.UpdateItemPollResult(ctx, it.ID, &models.Item{ _ = s.store.UpdateItemPollResult(ctx, it.ID, &models.Item{
BestPrice: &bp, BestPrice: &bp,
BestPriceCurrency: best.Currency,
BestPriceStore: best.Store, BestPriceStore: best.Store,
BestPriceURL: best.URL, BestPriceURL: best.URL,
BestPriceImageURL: best.ImageURL, BestPriceImageURL: best.ImageURL,
@@ -365,6 +375,7 @@ func (s *Scheduler) ExecutePlan(ctx context.Context, p actorPlan) ([]apify.Unifi
Store: l.Store, Store: l.Store,
ImageURL: l.ImageURL, ImageURL: l.ImageURL,
Source: apify.SourceActiveEbay, Source: apify.SourceActiveEbay,
EndsAt: l.EndsAt,
}) })
} }
default: default:
@@ -549,6 +560,8 @@ func (s *Scheduler) buildInputsForQuery(it models.Item, query string, markets []
MarketplaceID: ebay.MarketplaceID(mk), MarketplaceID: ebay.MarketplaceID(mk),
Query: query, Query: query,
ListingType: it.ListingType, ListingType: it.ListingType,
Condition: it.Condition,
Region: it.Region,
Limit: 30, Limit: 30,
}, },
}) })

14
main.go
View File

@@ -27,8 +27,19 @@ import (
func main() { func main() {
configPath := flag.String("config", "config.toml", "path to config TOML file") configPath := flag.String("config", "config.toml", "path to config TOML file")
debug := flag.Bool("debug", false, "enable debug-level logging (verbose; raw external payloads logged)")
flag.Parse() flag.Parse()
level := slog.LevelInfo
if *debug {
level = slog.LevelDebug
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})))
if *debug {
slog.Debug("debug logging enabled")
}
if err := run(*configPath); err != nil { if err := run(*configPath); err != nil {
slog.Error("fatal", "err", err) slog.Error("fatal", "err", err)
os.Exit(1) os.Exit(1)
@@ -74,6 +85,9 @@ func run(configPath string) error {
Addr: addr, Addr: addr,
Handler: app.Routes(), Handler: app.Routes(),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
} }
errCh := make(chan error, 1) errCh := make(chan error, 1)

View File

@@ -26,16 +26,54 @@ a { color: var(--accent); }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
.v-card { .v-card {
background: var(--surface); position: relative;
isolation: isolate;
background:
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: 8px; border-radius: 10px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
transition:
transform 180ms ease,
box-shadow 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 {
transform: translateY(-2px);
box-shadow:
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 {
background: var(--surface); position: relative;
isolation: isolate;
background:
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: 8px; border-radius: 10px;
} }
.v-divider { border-top: 1px solid var(--border); } .v-divider { border-top: 1px solid var(--border); }
@@ -138,6 +176,19 @@ a:hover { text-decoration: underline; }
} }
.v-side-nav a:hover { color: white; } .v-side-nav a:hover { color: white; }
/* The brand wordmark at the top of the sidebar is also an anchor (→ /), but
shouldn't pick up the active-item border-left / padding treatment that
the nav links get. Higher specificity overrides .v-side-nav a defaults. */
.v-side-nav a.v-side-brand {
border-left: 0;
color: var(--text);
text-decoration: none;
}
.v-side-nav a.v-side-brand:hover {
text-decoration: none;
filter: brightness(1.15);
}
.v-veola-portrait { .v-veola-portrait {
background: #f3ead8; background: #f3ead8;
border-radius: 12px; border-radius: 12px;
@@ -173,7 +224,9 @@ table.v-table td {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
vertical-align: middle; vertical-align: middle;
} }
table.v-table tr:hover td { background: rgba(255,255,255,0.03); } table.v-table td { transition: background 140ms ease; }
table.v-table tr { transition: transform 140ms ease; }
table.v-table tbody tr:hover td { background: rgba(0, 164, 228, 0.08); }
.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); }
@@ -210,3 +263,214 @@ table.v-table tr:hover td { background: rgba(255,255,255,0.03); }
.htmx-indicator { display: none; } .htmx-indicator { display: none; }
.htmx-request .htmx-indicator, .htmx-request .htmx-indicator,
.htmx-request.htmx-indicator { display: inline-flex; } .htmx-request.htmx-indicator { display: inline-flex; }
/* flair.js adds .v-just-swapped to any htmx swap target for ~400ms, giving
refreshed regions a soft fade-in instead of an abrupt content jump. */
@keyframes v-fade-in-up {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.v-just-swapped { animation: v-fade-in-up 240ms ease-out; }
/* Ending-soon strip: one global "next auction to close" banner. flair.js
keeps the countdown live. Subtle by default; pulses red when inside the
last 5 minutes via .v-countdown-critical on the inner counter. */
.v-ending-strip {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.7rem 1rem;
border-radius: 8px;
background:
linear-gradient(90deg, rgba(245, 196, 0, 0.12), rgba(0, 164, 228, 0.08));
border: 1px solid rgba(245, 196, 0, 0.35);
}
.v-ending-label {
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 700;
color: var(--yellow);
white-space: nowrap;
}
.v-ending-title { flex: 1; min-width: 0; }
.v-ending-countdown {
font-size: 1.15rem;
font-weight: 700;
color: var(--yellow);
}
/* Per-row + strip countdown urgency states. Default reads as neutral muted
text so 6-day countdowns don't shout; urgency tints kick in only when
flair.js flips the class. */
.v-countdown { color: var(--text-2); }
.v-countdown-urgent { color: var(--yellow); font-weight: 600; }
.v-countdown-critical {
color: var(--danger);
font-weight: 700;
animation: v-pulse 1.2s ease-in-out infinite;
}
.v-countdown-ended { color: var(--text-2); font-style: italic; }
@keyframes v-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
/* Sparkline cells in the items list. Color follows trend: green when the
latest price is meaningfully below the running average (good news for a
watchlist), red when it's risen, neutral otherwise. */
.v-sparkline { display: block; overflow: visible; }
.v-spark-down { color: var(--success); filter: drop-shadow(0 0 4px rgba(0, 228, 164, 0.45)); }
.v-spark-up { color: var(--danger); filter: drop-shadow(0 0 4px rgba(232, 64, 64, 0.40)); }
.v-spark-flat { color: var(--text-2); }
/* Trend arrow rendered next to Best Price. Same palette as the sparkline,
so a glance at the column reads consistently. */
.v-trend { font-size: 0.95rem; font-weight: 700; }
.v-trend-down { color: var(--success); }
.v-trend-up { color: var(--danger); }
.v-trend-flat { color: var(--text-2); }
/* Mascot "deal" moment: Veola appears next to an item's name only when the
current best price is at or below target. Small, animated, decorative —
purely a delight hit on top of the existing "Deal" badge. */
.v-deal-mascot {
width: 24px;
height: 24px;
border-radius: 6px;
object-fit: cover;
background: #f3ead8;
padding: 1px;
box-shadow: 0 0 0 1px rgba(0, 228, 164, 0.6), 0 0 12px rgba(0, 228, 164, 0.45);
animation: v-deal-bob 2.4s ease-in-out infinite;
}
@keyframes v-deal-bob {
0%, 100% { transform: translateY(0) rotate(-2deg); }
50% { transform: translateY(-2px) rotate(2deg); }
}
@media (prefers-reduced-motion: reduce) {
.v-deal-mascot { animation: none; }
}
/* --- Login / Setup chrome -----------------------------------------------
The auth pages use the Bare layout (no sidebar) so the form has to carry
its own visual weight. The v-auth-* classes give it that: a glassy card
for the form, a softly-rotating conic halo behind the mascot, a
typeset wordmark + tagline, and the right column is left translucent so
the global aurora reaches edge-to-edge instead of stopping at a flat
blue block. */
.v-auth-wordmark {
font-family: 'Outfit', system-ui, sans-serif;
text-shadow: 0 2px 14px rgba(0, 164, 228, 0.30);
}
.v-auth-card {
/* Slightly heavier shadow + inner glow ring since the card stands
alone on the page with no sidebar context. */
box-shadow:
0 16px 44px rgba(0, 0, 80, 0.55),
0 0 0 1px rgba(0, 164, 228, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.v-auth-tagline {
margin-top: 1.5rem;
text-align: center;
letter-spacing: 0.34em;
font-size: 0.72rem;
text-transform: uppercase;
color: var(--text-2);
text-shadow: 0 0 12px rgba(0, 164, 228, 0.35);
}
.v-auth-portrait-col {
/* Translucent overlay instead of a solid block: a soft inward vignette
plus a faint diagonal sheen, keeping the column distinct from the
form side without hiding the aurora behind it. */
background:
radial-gradient(ellipse at center, rgba(0, 0, 0, 0.0) 30%, rgba(0, 0, 0, 0.25) 100%),
linear-gradient(135deg, rgba(0, 164, 228, 0.05) 0%, rgba(245, 196, 0, 0.04) 100%);
position: relative;
overflow: hidden;
}
.v-auth-portrait-halo {
position: relative;
isolation: isolate;
width: min(420px, 80%);
display: flex;
align-items: center;
justify-content: center;
animation: v-auth-portrait-float 7s ease-in-out infinite;
will-change: transform;
}
/* Rotating conic-gradient halo. Sits behind the portrait via z-index -1
inside the isolated stacking context, blurred so it reads as a glow
rather than a hard rainbow. */
.v-auth-portrait-halo::before {
content: "";
position: absolute;
inset: -15%;
border-radius: 50%;
background: conic-gradient(
from 180deg,
rgba(0, 164, 228, 0.55),
rgba(245, 196, 0, 0.40),
rgba(232, 64, 64, 0.30),
rgba(0, 228, 164, 0.40),
rgba(0, 164, 228, 0.55)
);
filter: blur(50px);
z-index: -1;
animation: v-auth-portrait-spin 22s linear infinite;
will-change: transform;
}
/* A second, slower halo gives the impression of depth: two layers of
light rotating at different speeds keep the eye from locking onto a
single repeating pattern. */
.v-auth-portrait-halo::after {
content: "";
position: absolute;
inset: -25%;
border-radius: 50%;
background: conic-gradient(
from 0deg,
rgba(0, 228, 164, 0.18),
rgba(0, 164, 228, 0.10),
rgba(245, 196, 0, 0.14),
rgba(0, 228, 164, 0.18)
);
filter: blur(70px);
z-index: -1;
animation: v-auth-portrait-spin 42s linear infinite reverse;
will-change: transform;
opacity: 0.85;
}
@keyframes v-auth-portrait-spin {
to { transform: rotate(360deg); }
}
@keyframes v-auth-portrait-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@media (prefers-reduced-motion: reduce) {
.v-auth-portrait-halo,
.v-auth-portrait-halo::before,
.v-auth-portrait-halo::after { animation: none; }
}
@media (prefers-reduced-motion: reduce) {
.v-card { transition: none; }
.v-card:hover { transform: none; }
table.v-table td, table.v-table tr { transition: none; }
.v-just-swapped { animation: none; }
.v-countdown-critical { animation: none; }
}

118
static/css/retro.css Normal file
View File

@@ -0,0 +1,118 @@
/* retro.css — optional "retro-arcade identity" layer.
*
* Entirely additive. To revert, delete this file and remove its <link> tag
* from templates/layout.templ. Nothing else depends on it.
*
* What this adds:
* 1. Two independently-animated aurora blobs that drift across the
* viewport on different paths — the real "shifting light" feel,
* not a synchronized slide.
* 2. Dot-grid overlay over the whole viewport.
* 3. Heavier display headings with a soft accent glow.
*
* Both blobs live on html pseudo-elements at z-index -2 / -1, sitting
* behind everything else so card glassiness still reads on top.
*/
body {
/* Let the html-level aurora show through. app.css set a solid blue here;
dropping it here unlocks the gradient. Removing retro.css restores the
original solid background automatically. */
background: transparent;
}
html {
background-color: var(--bg);
/* Static dot-grid baked into the root background so the two pseudo-element
slots stay free for the animated blobs. */
background-image: radial-gradient(
circle,
rgba(255, 255, 255, 0.045) 1px,
transparent 1px
);
background-size: 26px 26px;
background-attachment: fixed;
}
/* Two aurora blobs animated with seven waypoints each (no `alternate`, so
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
repeating pattern even though each blob is technically on a loop.
Opacity also wobbles so the lights "breathe" instead of just sliding. */
html::before {
content: "";
position: fixed;
inset: 0;
z-index: -2;
pointer-events: none;
background: radial-gradient(
closest-side,
rgba(0, 164, 228, 0.60),
rgba(0, 164, 228, 0.0) 70%
);
width: 70vmax;
height: 70vmax;
border-radius: 50%;
filter: blur(60px);
/* Centered base; keyframes drive all motion from here. */
top: 50%; left: 50%;
margin-top: -35vmax; margin-left: -35vmax;
animation: v-retro-drift-a 41s cubic-bezier(.6,.1,.4,.9) infinite;
will-change: transform, opacity;
}
html::after {
content: "";
position: fixed;
inset: 0;
z-index: -2;
pointer-events: none;
background: radial-gradient(
closest-side,
rgba(245, 196, 0, 0.50),
rgba(245, 196, 0, 0.0) 70%
);
width: 55vmax;
height: 55vmax;
border-radius: 50%;
filter: blur(70px);
top: 50%; left: 50%;
margin-top: -27.5vmax; margin-left: -27.5vmax;
animation: v-retro-drift-b 29s cubic-bezier(.5,.2,.3,.8) infinite;
will-change: transform, opacity;
}
@keyframes v-retro-drift-a {
0% { transform: translate3d(-30vw, -20vh, 0) scale(1.00); opacity: 0.85; }
14% { transform: translate3d( 5vw, -28vh, 0) scale(1.18); opacity: 1.00; }
29% { transform: translate3d( 32vw, -10vh, 0) scale(0.92); opacity: 0.75; }
43% { transform: translate3d( 18vw, 22vh, 0) scale(1.10); opacity: 0.95; }
58% { transform: translate3d(-15vw, 30vh, 0) scale(1.05); opacity: 0.80; }
72% { transform: translate3d(-38vw, 8vh, 0) scale(0.95); opacity: 1.00; }
86% { transform: translate3d(-20vw, -12vh, 0) scale(1.12); opacity: 0.90; }
100% { transform: translate3d(-30vw, -20vh, 0) scale(1.00); opacity: 0.85; }
}
@keyframes v-retro-drift-b {
0% { transform: translate3d( 28vw, -25vh, 0) scale(1.00); opacity: 0.70; }
17% { transform: translate3d( 0vw, -10vh, 0) scale(1.20); opacity: 0.95; }
33% { transform: translate3d(-30vw, 8vh, 0) scale(0.90); opacity: 0.80; }
48% { transform: translate3d(-12vw, 28vh, 0) scale(1.10); opacity: 1.00; }
64% { transform: translate3d( 22vw, 18vh, 0) scale(0.95); opacity: 0.75; }
80% { transform: translate3d( 36vw, -2vh, 0) scale(1.15); opacity: 0.90; }
100% { transform: translate3d( 28vw, -25vh, 0) scale(1.00); opacity: 0.70; }
}
/* Display headings: heavier, slightly tighter, with an accent glow that ties
into the card border gradient. Class-free selectors so existing Tailwind
utilities (text-3xl etc.) stack on top untouched. */
h1, h2 {
font-weight: 800;
letter-spacing: -0.02em;
text-shadow: 0 2px 14px rgba(0, 164, 228, 0.30);
}
@media (prefers-reduced-motion: reduce) {
html::before, html::after { animation: none; }
}

File diff suppressed because one or more lines are too long

BIN
static/img/veola.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

130
static/js/flair.js Normal file
View File

@@ -0,0 +1,130 @@
// Lightweight visual flourishes:
// - count-up animation on [data-countup] elements at page load
// - fade-in on htmx swap targets via a transient .v-just-swapped class
// Respects prefers-reduced-motion by no-oping both effects.
(function () {
const prefersReduced =
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
function animateCount(el) {
if (prefersReduced) return;
const raw = el.textContent.trim();
const m = raw.match(/^(\$|£|€|¥)?(-?[\d,]+(?:\.\d+)?)$/);
if (!m) return;
const prefix = m[1] || "";
const numeric = m[2].replace(/,/g, "");
const target = parseFloat(numeric);
if (!isFinite(target)) return;
const decimals = numeric.includes(".") ? numeric.split(".")[1].length : 0;
const format = (v) =>
prefix + (decimals > 0 ? v.toFixed(decimals) : Math.floor(v).toString());
const duration = 650;
const start = performance.now();
el.textContent = format(0);
function tick(now) {
const t = Math.min(1, (now - start) / duration);
const v = target * easeOutCubic(t);
el.textContent = format(v);
if (t < 1) {
requestAnimationFrame(tick);
} else {
el.textContent = format(target);
}
}
requestAnimationFrame(tick);
}
function runCountUps() {
document.querySelectorAll("[data-countup]").forEach(animateCount);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", runCountUps);
} else {
runCountUps();
}
document.addEventListener("htmx:afterSwap", function (evt) {
if (prefersReduced) return;
const target = evt.detail && evt.detail.target;
if (!target || !target.classList) return;
target.classList.add("v-just-swapped");
setTimeout(() => target.classList.remove("v-just-swapped"), 400);
});
// Auction countdowns. Each [data-countdown] reads its sibling/ancestor's
// data-ends-at ISO timestamp. Above 1h remaining we update once a minute
// (text barely changes); inside the last hour we tick every second and tint
// urgent / critical so the eye lands on it. Past the end we render "ended"
// and stop ticking that node.
function formatRemaining(ms) {
if (ms <= 0) return { text: "ended", urgent: false, critical: false };
const s = Math.floor(ms / 1000);
const days = Math.floor(s / 86400);
const hours = Math.floor((s % 86400) / 3600);
const minutes = Math.floor((s % 3600) / 60);
const seconds = s % 60;
const urgent = ms < 60 * 60 * 1000;
const critical = ms < 5 * 60 * 1000;
let text;
if (days > 0) text = days + "d " + hours + "h";
else if (hours > 0) text = hours + "h " + minutes + "m";
else if (minutes > 0) text = minutes + "m " + String(seconds).padStart(2, "0") + "s";
else text = seconds + "s";
return { text, urgent, critical };
}
function resolveEndsAt(el) {
let cur = el;
while (cur && cur.dataset && !cur.dataset.endsAt) cur = cur.parentElement;
return cur && cur.dataset ? cur.dataset.endsAt : null;
}
function tickCountdown(el, endsAtMs) {
const remaining = endsAtMs - Date.now();
const f = formatRemaining(remaining);
el.textContent = f.text;
el.classList.toggle("v-countdown-urgent", f.urgent && !f.critical);
el.classList.toggle("v-countdown-critical", f.critical && remaining > 0);
el.classList.toggle("v-countdown-ended", remaining <= 0);
return remaining;
}
function startCountdowns() {
document.querySelectorAll("[data-countdown]").forEach(function (el) {
const iso = resolveEndsAt(el);
if (!iso) return;
const endsAtMs = Date.parse(iso);
if (!isFinite(endsAtMs)) return;
let timer = null;
function loop() {
const remaining = tickCountdown(el, endsAtMs);
if (remaining <= 0) {
if (timer) clearTimeout(timer);
return;
}
// Tick every second below an hour, every 30 seconds otherwise. The
// long-interval path keeps "3d 4h" from causing constant repaints.
const interval = remaining < 60 * 60 * 1000 ? 1000 : 30000;
timer = setTimeout(loop, interval);
}
loop();
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", startCountdowns);
} else {
startCountdowns();
}
// After an htmx swap, freshly-inserted countdowns need to be started too.
document.addEventListener("htmx:afterSwap", startCountdowns);
})();

View File

@@ -9,7 +9,29 @@
return; return;
} }
var data = JSON.parse(canvas.dataset.chart); var data = JSON.parse(canvas.dataset.chart);
new Chart(canvas.getContext('2d'), { var ctx = canvas.getContext('2d');
// Vertical gradient fill — bright accent at the top, transparent at the
// baseline — makes the chart read at a glance instead of as a thin line.
var fill = ctx.createLinearGradient(0, 0, 0, canvas.clientHeight || 200);
fill.addColorStop(0, 'rgba(0, 228, 164, 0.45)');
fill.addColorStop(1, 'rgba(0, 228, 164, 0.00)');
// Chart.js plugin that paints a soft glow under the line stroke before
// the dataset draws. Cheap enough to keep on by default; respects
// prefers-reduced-motion only insofar as nothing animates here.
var glowPlugin = {
id: 'priceLineGlow',
beforeDatasetDraw: function (chart) {
var c = chart.ctx;
c.save();
c.shadowColor = 'rgba(0, 228, 164, 0.55)';
c.shadowBlur = 12;
},
afterDatasetDraw: function (chart) { chart.ctx.restore(); }
};
new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
labels: data.labels, labels: data.labels,
@@ -17,19 +39,37 @@
label: 'Best price', label: 'Best price',
data: data.points, data: data.points,
borderColor: '#00e4a4', borderColor: '#00e4a4',
backgroundColor: 'rgba(0,228,164,0.15)', borderWidth: 2.5,
pointBackgroundColor: '#e84040', backgroundColor: fill,
pointBackgroundColor: '#ffffff',
pointBorderColor: '#00e4a4',
pointBorderWidth: 1.5,
pointRadius: 3, pointRadius: 3,
tension: 0.25, pointHoverRadius: 5,
tension: 0.3,
fill: true fill: true
}] }]
}, },
options: { options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: { scales: {
x: { ticks: { color: '#a8c0f0' }, grid: { color: 'rgba(255,255,255,0.07)' } }, x: { ticks: { color: '#a8c0f0' }, grid: { color: 'rgba(255,255,255,0.06)' } },
y: { ticks: { color: '#a8c0f0' }, grid: { color: 'rgba(255,255,255,0.07)' } } y: { ticks: { color: '#a8c0f0' }, grid: { color: 'rgba(255,255,255,0.06)' } }
}, },
plugins: { legend: { labels: { color: '#ffffff' } } } plugins: {
} legend: { labels: { color: '#ffffff' } },
tooltip: {
backgroundColor: 'rgba(20, 32, 80, 0.95)',
borderColor: 'rgba(0, 164, 228, 0.6)',
borderWidth: 1,
titleColor: '#ffffff',
bodyColor: '#a8c0f0',
padding: 10
}
}
},
plugins: [glowPlugin]
}); });
})(); })();

View File

@@ -51,7 +51,7 @@ templ DashboardBody(d DashboardData) {
<div class="grid md:grid-cols-2 gap-4 mb-6"> <div class="grid md:grid-cols-2 gap-4 mb-6">
<div class="v-card p-5"> <div class="v-card p-5">
<div class="v-muted text-sm uppercase tracking-wide">Potential Spend</div> <div class="v-muted text-sm uppercase tracking-wide">Potential Spend</div>
<div class="font-mono text-4xl mt-2">{ fmt.Sprintf("$%.2f", d.Stats.PotentialSpend) }</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> <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> <div class="v-muted text-xs mt-1">{ fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount) }</div>
@@ -59,7 +59,7 @@ templ DashboardBody(d DashboardData) {
</div> </div>
<div class="v-card p-5"> <div class="v-card p-5">
<div class="v-muted text-sm uppercase tracking-wide">Money Saved</div> <div class="v-muted text-sm uppercase tracking-wide">Money Saved</div>
<div class="font-mono text-4xl mt-2 v-price-deal">{ fmt.Sprintf("$%.2f", d.Stats.MoneySaved) }</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</div> <div class="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items</div>
</div> </div>
</div> </div>
@@ -108,7 +108,7 @@ templ DashboardBody(d DashboardData) {
templ statCard(label, value, sub string) { templ statCard(label, value, sub string) {
<div class="v-card p-4"> <div class="v-card p-4">
<div class="v-muted text-xs uppercase tracking-wide">{ label }</div> <div class="v-muted text-xs uppercase tracking-wide">{ label }</div>
<div class="font-mono text-3xl mt-1">{ value }</div> <div class="font-mono text-3xl mt-1" data-countup>{ value }</div>
if sub != "" { if sub != "" {
<div class="v-muted text-xs mt-1">{ sub }</div> <div class="v-muted text-xs mt-1">{ sub }</div>
} }

View File

@@ -88,14 +88,14 @@ func DashboardBody(d DashboardData) templ.Component {
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, 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\">") 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("$%.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: 54, Col: 87} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 54, Col: 100}
} }
_, 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 {
@@ -137,14 +137,14 @@ func DashboardBody(d DashboardData) templ.Component {
return templ_7745c5c3_Err 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\">") 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
} }
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("$%.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: 62, Col: 96} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 62, Col: 109}
} }
_, 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 {
@@ -346,14 +346,14 @@ func statCard(label, value, sub string) templ.Component {
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, 28, "</div><div class=\"font-mono text-3xl mt-1\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><div class=\"font-mono text-3xl mt-1\" data-countup>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(value) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(value)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 111, Col: 46} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 111, Col: 59}
} }
_, 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 {

View File

@@ -16,6 +16,43 @@ type ItemFormData struct {
func itemSelected(have, want string) bool { return have == want } func itemSelected(have, want string) bool { return have == want }
type selectOpt struct {
Value string
Label string
}
// conditionOptions are the eBay-only item-condition filters. Values match the
// vocabulary mapped to Browse API condition IDs in internal/ebay.
func conditionOptions() []selectOpt {
return []selectOpt{
{"", "— any —"},
{"new", "New"},
{"used", "Used"},
{"refurbished", "Refurbished"},
{"parts", "For parts / not working"},
}
}
// regionOptions are the eBay-only item-location filters, keyed by ISO 3166-1
// alpha-2 country code (the value the Browse API itemLocationCountry filter
// expects).
func regionOptions() []selectOpt {
return []selectOpt{
{"", "— any —"},
{"US", "United States"},
{"GB", "United Kingdom"},
{"DE", "Germany"},
{"FR", "France"},
{"IT", "Italy"},
{"ES", "Spain"},
{"CA", "Canada"},
{"AU", "Australia"},
{"JP", "Japan"},
{"CN", "China"},
{"HK", "Hong Kong"},
}
}
type marketplaceOpt struct { type marketplaceOpt struct {
Value string Value string
Label string Label string
@@ -226,6 +263,26 @@ templ itemFormInner(d ItemFormData) {
<div class="v-muted text-xs mt-1">Drop results whose title contains any of these. Case-insensitive substring match.</div> <div class="v-muted text-xs mt-1">Drop results whose title contains any of these. Case-insensitive substring match.</div>
</div> </div>
</div> </div>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="v-label">Condition</label>
<select class="v-select" name="condition">
for _, o := range conditionOptions() {
<option value={ o.Value } selected?={ itemSelected(d.Item.Condition, o.Value) }>{ o.Label }</option>
}
</select>
<div class="v-muted text-xs mt-1">eBay marketplaces only. Ignored for Yahoo JP, Mercari, and custom actors.</div>
</div>
<div>
<label class="v-label">Item Location</label>
<select class="v-select" name="region">
for _, o := range regionOptions() {
<option value={ o.Value } selected?={ itemSelected(d.Item.Region, o.Value) }>{ o.Label }</option>
}
</select>
<div class="v-muted text-xs mt-1">Restrict to items located in this country. eBay marketplaces only.</div>
</div>
</div>
<label class="flex items-center gap-2"> <label class="flex items-center gap-2">
<input type="checkbox" name="include_out_of_stock" checked?={ d.Item.IncludeOutOfStock } value="1"/> <input type="checkbox" name="include_out_of_stock" checked?={ d.Item.IncludeOutOfStock } value="1"/>
<span>Include out-of-stock results</span> <span>Include out-of-stock results</span>

View File

@@ -24,6 +24,43 @@ type ItemFormData struct {
func itemSelected(have, want string) bool { return have == want } func itemSelected(have, want string) bool { return have == want }
type selectOpt struct {
Value string
Label string
}
// conditionOptions are the eBay-only item-condition filters. Values match the
// vocabulary mapped to Browse API condition IDs in internal/ebay.
func conditionOptions() []selectOpt {
return []selectOpt{
{"", "— any —"},
{"new", "New"},
{"used", "Used"},
{"refurbished", "Refurbished"},
{"parts", "For parts / not working"},
}
}
// regionOptions are the eBay-only item-location filters, keyed by ISO 3166-1
// alpha-2 country code (the value the Browse API itemLocationCountry filter
// expects).
func regionOptions() []selectOpt {
return []selectOpt{
{"", "— any —"},
{"US", "United States"},
{"GB", "United Kingdom"},
{"DE", "Germany"},
{"FR", "France"},
{"IT", "Italy"},
{"ES", "Spain"},
{"CA", "Canada"},
{"AU", "Australia"},
{"JP", "Japan"},
{"CN", "China"},
{"HK", "Hong Kong"},
}
}
type marketplaceOpt struct { type marketplaceOpt struct {
Value string Value string
Label string Label string
@@ -138,7 +175,7 @@ func itemFormBody(d ItemFormData) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Name) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 104, Col: 22} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 141, Col: 22}
} }
_, 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 {
@@ -167,7 +204,7 @@ func itemFormBody(d ItemFormData) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(e) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(e)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 113, Col: 13} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 150, Col: 13}
} }
_, 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 {
@@ -223,7 +260,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var5 templ.SafeURL var templ_7745c5c3_Var5 templ.SafeURL
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(formAction(d)) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(formAction(d))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 125, Col: 24} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 162, Col: 24}
} }
_, 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 {
@@ -254,7 +291,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.Name) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 139, Col: 58} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 176, Col: 58}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -282,7 +319,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(c) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(c)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 146, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 183, Col: 23}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -305,7 +342,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(c) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(c)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 146, Col: 64} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 183, 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 {
@@ -323,7 +360,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(newCategory(d.Item.Category, d.Categories)) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(newCategory(d.Item.Category, d.Categories))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 149, Col: 110} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 186, Col: 110}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -336,7 +373,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.SearchQuery) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.SearchQuery)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 154, Col: 150} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 191, Col: 150}
} }
_, 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 {
@@ -349,7 +386,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.URL) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.URL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 159, Col: 55} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 196, Col: 55}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -362,7 +399,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(optFloat(d.Item.TargetPrice)) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(optFloat(d.Item.TargetPrice))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 165, Col: 119} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 202, Col: 119}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -375,7 +412,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.NtfyTopic) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.NtfyTopic)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 170, Col: 69} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 207, Col: 69}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -393,7 +430,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(p) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(p)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 176, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 213, Col: 23}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -416,7 +453,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 176, Col: 80} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 213, 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 {
@@ -439,7 +476,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", opt.Minutes)) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", opt.Minutes))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 186, Col: 52} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 223, Col: 52}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -462,7 +499,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 186, Col: 122} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 223, Col: 122}
} }
_, 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 {
@@ -485,7 +522,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 195, Col: 64} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 232, Col: 64}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -508,7 +545,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 196, Col: 22} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 233, Col: 22}
} }
_, 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 {
@@ -526,7 +563,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(customMarketplacesCSV(d.Item)) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(customMarketplacesCSV(d.Item))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 200, Col: 103} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 237, Col: 103}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -554,7 +591,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(lt) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(lt)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 212, Col: 24} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 249, Col: 24}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -577,7 +614,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(lt) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(lt)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 212, Col: 82} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 249, Col: 82}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -595,7 +632,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var23 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(optFloat(d.Item.MinPrice)) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(optFloat(d.Item.MinPrice))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 220, Col: 113} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 257, Col: 113}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -608,92 +645,184 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.ExcludeKeywords) templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.ExcludeKeywords)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 225, Col: 137} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 262, Col: 137}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
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, 57, "</textarea><div class=\"v-muted text-xs mt-1\">Drop results whose title contains any of these. Case-insensitive substring match.</div></div></div><label class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"include_out_of_stock\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</textarea><div class=\"v-muted text-xs mt-1\">Drop results whose title contains any of these. Case-insensitive substring match.</div></div></div><div class=\"grid md:grid-cols-2 gap-4\"><div><label class=\"v-label\">Condition</label> <select class=\"v-select\" name=\"condition\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, o := range conditionOptions() {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(o.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 271, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if itemSelected(d.Item.Condition, o.Value) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(o.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 271, Col: 95}
}
_, 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, 62, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</select><div class=\"v-muted text-xs mt-1\">eBay marketplaces only. Ignored for Yahoo JP, Mercari, and custom actors.</div></div><div><label class=\"v-label\">Item Location</label> <select class=\"v-select\" name=\"region\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, o := range regionOptions() {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(o.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 280, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if itemSelected(d.Item.Region, o.Value) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(o.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 280, Col: 92}
}
_, 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, 68, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</select><div class=\"v-muted text-xs mt-1\">Restrict to items located in this country. eBay marketplaces only.</div></div></div><label class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"include_out_of_stock\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.Item.IncludeOutOfStock { if d.Item.IncludeOutOfStock {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " checked") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " checked")
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, 59, " value=\"1\"> <span>Include out-of-stock results</span></label> <details class=\"v-card-flat p-4\"><summary class=\"cursor-pointer font-semibold\">Advanced</summary><div class=\"v-muted text-sm mt-2\">Leave blank to use the configured default for the selected marketplace.</div><div class=\"grid md:grid-cols-2 gap-4 mt-3\"><div><label class=\"v-label\">Active Listings Actor</label> <input class=\"v-input\" name=\"actor_active\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " value=\"1\"> <span>Include out-of-stock results</span></label> <details class=\"v-card-flat p-4\"><summary class=\"cursor-pointer font-semibold\">Advanced</summary><div class=\"v-muted text-sm mt-2\">Leave blank to use the configured default for the selected marketplace.</div><div class=\"grid md:grid-cols-2 gap-4 mt-3\"><div><label class=\"v-label\">Active Listings Actor</label> <input class=\"v-input\" name=\"actor_active\" value=\"")
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_Var29 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorActive) templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorActive)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 239, Col: 74} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 296, Col: 74}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
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, 60, "\" placeholder=\"from config\"></div><div><label class=\"v-label\">Sold Listings Actor</label> <input class=\"v-input\" name=\"actor_sold\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" placeholder=\"from config\"></div><div><label class=\"v-label\">Sold Listings Actor</label> <input class=\"v-input\" name=\"actor_sold\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var26 string var templ_7745c5c3_Var30 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorSold) templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorSold)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 243, Col: 70} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 300, Col: 70}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
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, 61, "\" placeholder=\"from config\"></div><div><label class=\"v-label\">Price Comparison Actor</label> <input class=\"v-input\" name=\"actor_price_compare\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" placeholder=\"from config\"></div><div><label class=\"v-label\">Price Comparison Actor</label> <input class=\"v-input\" name=\"actor_price_compare\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var27 string var templ_7745c5c3_Var31 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorPriceCompare) templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorPriceCompare)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 247, Col: 87} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 304, Col: 87}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
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, 62, "\" placeholder=\"from config\"></div><label class=\"flex items-center gap-2 mt-6\"><input type=\"checkbox\" name=\"use_price_comparison\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" placeholder=\"from config\"></div><label class=\"flex items-center gap-2 mt-6\"><input type=\"checkbox\" name=\"use_price_comparison\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.Item.UsePriceComparison { if d.Item.UsePriceComparison {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " checked") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " checked")
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, 64, " value=\"1\"> <span>Use price comparison actor in addition to active listings</span></label></div></details><div class=\"flex items-center gap-3 pt-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, " value=\"1\"> <span>Use price comparison actor in addition to active listings</span></label></div></details><div class=\"flex items-center gap-3 pt-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.IsEdit { if d.IsEdit {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<button class=\"v-btn\" type=\"submit\">Save</button> <a class=\"v-btn-ghost\" href=\"/items\">Cancel</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<button class=\"v-btn\" type=\"submit\">Save</button> <a class=\"v-btn-ghost\" href=\"/items\">Cancel</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<button class=\"v-btn\" type=\"submit\">Preview</button> <a class=\"v-btn-ghost\" href=\"/items\">Cancel</a> <span id=\"preview-loading\" class=\"htmx-indicator v-muted text-sm flex items-center gap-2\"><span class=\"v-spinner\"></span> Running preview… apify runs can take 3060s.</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<button class=\"v-btn\" type=\"submit\">Preview</button> <a class=\"v-btn-ghost\" href=\"/items\">Cancel</a> <span id=\"preview-loading\" class=\"htmx-indicator v-muted text-sm flex items-center gap-2\"><span class=\"v-spinner\"></span> Running preview… apify runs can take 3060s.</span>")
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, 67, "</div></form>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</div></form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if !d.IsEdit { if !d.IsEdit {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div id=\"preview-target\" class=\"mt-6\"></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div id=\"preview-target\" class=\"mt-6\"></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -744,9 +873,9 @@ func ItemForm(d ItemFormData) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var28 := templ.GetChildren(ctx) templ_7745c5c3_Var32 := templ.GetChildren(ctx)
if templ_7745c5c3_Var28 == nil { if templ_7745c5c3_Var32 == nil {
templ_7745c5c3_Var28 = templ.NopComponent templ_7745c5c3_Var32 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Layout(d.Page, itemFormBody(d)).Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Layout(d.Page, itemFormBody(d)).Render(ctx, templ_7745c5c3_Buffer)

View File

@@ -35,6 +35,8 @@ type FormValues struct {
IncludeOutOfStock bool IncludeOutOfStock bool
Marketplaces []string Marketplaces []string
ListingType string ListingType string
Condition string
Region string
ActorActive string ActorActive string
ActorSold string ActorSold string
ActorPriceCompare string ActorPriceCompare string
@@ -136,6 +138,8 @@ templ confirmForm(d PreviewData) {
@hidden("marketplace", m) @hidden("marketplace", m)
} }
@hidden("listing_type", d.Form.ListingType) @hidden("listing_type", d.Form.ListingType)
@hidden("condition", d.Form.Condition)
@hidden("region", d.Form.Region)
@hidden("actor_active", d.Form.ActorActive) @hidden("actor_active", d.Form.ActorActive)
@hidden("actor_sold", d.Form.ActorSold) @hidden("actor_sold", d.Form.ActorSold)
@hidden("actor_price_compare", d.Form.ActorPriceCompare) @hidden("actor_price_compare", d.Form.ActorPriceCompare)

View File

@@ -43,6 +43,8 @@ type FormValues struct {
IncludeOutOfStock bool IncludeOutOfStock bool
Marketplaces []string Marketplaces []string
ListingType string ListingType string
Condition string
Region string
ActorActive string ActorActive string
ActorSold string ActorSold string
ActorPriceCompare string ActorPriceCompare string
@@ -78,7 +80,7 @@ func ItemPreview(d PreviewData) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 48, Col: 17} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 50, Col: 17}
} }
_, 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 {
@@ -101,7 +103,7 @@ func ItemPreview(d PreviewData) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Found %d results for '%s'", len(d.Results), d.Form.SearchQuery)) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Found %d results for '%s'", len(d.Results), d.Form.SearchQuery))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 60, Col: 83} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 62, Col: 83}
} }
_, 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 {
@@ -144,7 +146,7 @@ func ItemPreview(d PreviewData) templ.Component {
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ImageURL) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ImageURL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 75, Col: 31} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 77, Col: 31}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -162,7 +164,7 @@ func ItemPreview(d PreviewData) templ.Component {
var templ_7745c5c3_Var5 templ.SafeURL var templ_7745c5c3_Var5 templ.SafeURL
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL)) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 78, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 80, Col: 40}
} }
_, 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 {
@@ -175,7 +177,7 @@ func ItemPreview(d PreviewData) templ.Component {
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 78, Col: 99} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 80, Col: 99}
} }
_, 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 {
@@ -188,7 +190,7 @@ func ItemPreview(d PreviewData) templ.Component {
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(r.Store) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(r.Store)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 79, Col: 48} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 81, Col: 48}
} }
_, 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 {
@@ -201,7 +203,7 @@ func ItemPreview(d PreviewData) templ.Component {
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(r.Price, r.Currency)) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(r.Price, r.Currency))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 81, Col: 64} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 83, 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 {
@@ -225,7 +227,7 @@ func ItemPreview(d PreviewData) templ.Component {
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("and %d more", len(d.Results)-6)) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("and %d more", len(d.Results)-6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 87, Col: 86} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 89, Col: 86}
} }
_, 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 {
@@ -248,7 +250,7 @@ func ItemPreview(d PreviewData) templ.Component {
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Prices range from %s to %s across %d stores", fmtNumber(d.MinPrice, d.Currency), fmtNumber(d.MaxPrice, d.Currency), d.StoreCount)) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Prices range from %s to %s across %d stores", fmtNumber(d.MinPrice, d.Currency), fmtNumber(d.MaxPrice, d.Currency), d.StoreCount))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 93, Col: 148} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 95, Col: 148}
} }
_, 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 {
@@ -301,7 +303,7 @@ func previewBest(d PreviewData) templ.Component {
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Results[d.BestIndex].ImageURL) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Results[d.BestIndex].ImageURL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 104, Col: 46} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 106, Col: 46}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -324,7 +326,7 @@ func previewBest(d PreviewData) templ.Component {
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(d.Results[d.BestIndex].Price, d.Currency)) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(d.Results[d.BestIndex].Price, d.Currency))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 110, Col: 94} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 112, Col: 94}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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 {
@@ -337,7 +339,7 @@ func previewBest(d PreviewData) templ.Component {
var templ_7745c5c3_Var14 templ.SafeURL var templ_7745c5c3_Var14 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(d.Results[d.BestIndex].URL)) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(d.Results[d.BestIndex].URL))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 111, Col: 77} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 113, Col: 77}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -350,7 +352,7 @@ func previewBest(d PreviewData) templ.Component {
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].Title) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 111, Col: 141} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 113, Col: 141}
} }
_, 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 {
@@ -363,7 +365,7 @@ func previewBest(d PreviewData) templ.Component {
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].Store) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].Store)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 112, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 114, Col: 68}
} }
_, 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 {
@@ -381,7 +383,7 @@ func previewBest(d PreviewData) templ.Component {
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].MatchedQuery) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].MatchedQuery)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 114, Col: 81} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 116, Col: 81}
} }
_, 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 {
@@ -429,7 +431,7 @@ func confirmForm(d PreviewData) templ.Component {
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 123, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 125, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -493,6 +495,14 @@ func confirmForm(d PreviewData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = hidden("condition", d.Form.Condition).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = hidden("region", d.Form.Region).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = hidden("actor_active", d.Form.ActorActive).Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = hidden("actor_active", d.Form.ActorActive).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
@@ -555,7 +565,7 @@ func hidden(name, value string) templ.Component {
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(name) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 149, Col: 33} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 153, Col: 33}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -568,7 +578,7 @@ func hidden(name, value string) templ.Component {
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(value) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(value)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 149, Col: 49} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 153, Col: 49}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -611,7 +621,7 @@ func hiddenBool(name string, value bool) templ.Component {
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(name) templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 154, Col: 34} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 158, Col: 34}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {

View File

@@ -2,6 +2,8 @@ package templates
import ( import (
"fmt" "fmt"
"math"
"strings"
"veola/internal/models" "veola/internal/models"
) )
@@ -11,6 +13,85 @@ type ItemsData struct {
Items []models.Item Items []models.Item
Categories []string Categories []string
SelectedCategory string SelectedCategory string
// PriceHistory holds the last ~20 points per item id, ascending in time.
// Nil/missing entries render an empty sparkline cell.
PriceHistory map[int64][]models.PricePoint
}
// sparklinePoints turns a price-history slice into an SVG polyline "points"
// attribute, normalized to a 80x24 viewBox with 2px padding so endpoints
// don't clip the stroke. Returns "" when there isn't enough history to draw.
func sparklinePoints(history []models.PricePoint) string {
if len(history) < 2 {
return ""
}
const w, h, pad = 80.0, 24.0, 2.0
minP, maxP := math.Inf(1), math.Inf(-1)
for _, p := range history {
if p.Price < minP {
minP = p.Price
}
if p.Price > maxP {
maxP = p.Price
}
}
span := maxP - minP
if span == 0 {
// All-equal series: draw a flat line through the middle.
span = 1
}
step := (w - 2*pad) / float64(len(history)-1)
parts := make([]string, len(history))
for i, p := range history {
x := pad + float64(i)*step
y := h - pad - ((p.Price-minP)/span)*(h-2*pad)
parts[i] = fmt.Sprintf("%.1f,%.1f", x, y)
}
return strings.Join(parts, " ")
}
// sparklineTrendClass compares the last point to the average of the prior
// points and returns a CSS class so the sparkline tints green (price down),
// red (up), or neutral. Threshold is ±3% to ignore tiny wobbles.
func sparklineTrendClass(history []models.PricePoint) string {
if len(history) < 2 {
return ""
}
last := history[len(history)-1].Price
var sum float64
for _, p := range history[:len(history)-1] {
sum += p.Price
}
avg := sum / float64(len(history)-1)
if avg == 0 {
return "v-spark-flat"
}
switch {
case last <= avg*0.97:
return "v-spark-down"
case last >= avg*1.03:
return "v-spark-up"
}
return "v-spark-flat"
}
// trendArrow returns the unicode arrow + a CSS class describing the direction.
// Same threshold logic as the sparkline. Empty when there's no trend signal.
func trendArrow(history []models.PricePoint) (glyph, class string) {
switch sparklineTrendClass(history) {
case "v-spark-down":
return "↓", "v-trend-down"
case "v-spark-up":
return "↑", "v-trend-up"
case "v-spark-flat":
return "→", "v-trend-flat"
}
return "", ""
}
// isDeal is the gate for the mascot "deal" moment on a row.
func isDeal(it models.Item) bool {
return it.BestPrice != nil && it.TargetPrice != nil && *it.BestPrice <= *it.TargetPrice
} }
templ itemsBody(d ItemsData) { templ itemsBody(d ItemsData) {
@@ -41,6 +122,7 @@ templ itemsBody(d ItemsData) {
<th>Category</th> <th>Category</th>
<th>Target</th> <th>Target</th>
<th>Best Price</th> <th>Best Price</th>
<th>Trend</th>
<th>Last Polled</th> <th>Last Polled</th>
<th>Status</th> <th>Status</th>
<th></th> <th></th>
@@ -48,7 +130,7 @@ templ itemsBody(d ItemsData) {
</thead> </thead>
<tbody id="items-tbody"> <tbody id="items-tbody">
for _, it := range d.Items { for _, it := range d.Items {
@itemRow(it, d.CSRFToken) @itemRow(it, d.CSRFToken, d.PriceHistory[it.ID])
} }
</tbody> </tbody>
</table> </table>
@@ -60,7 +142,7 @@ templ itemsBody(d ItemsData) {
templ itemsEmpty() { templ itemsEmpty() {
<div class="v-card p-8 flex flex-col md:flex-row items-center gap-6"> <div class="v-card p-8 flex flex-col md:flex-row items-center gap-6">
<div class="v-veola-portrait w-48 shrink-0"> <div class="v-veola-portrait w-48 shrink-0">
<img src="/static/img/veola.webp" alt="Veola"/> <img src="/static/img/veola.avif" alt="Veola"/>
</div> </div>
<div> <div>
<h2 class="text-xl font-semibold mb-2">Nothing on the watchlist.</h2> <h2 class="text-xl font-semibold mb-2">Nothing on the watchlist.</h2>
@@ -70,10 +152,15 @@ templ itemsEmpty() {
</div> </div>
} }
templ itemRow(it models.Item, csrf string) { templ itemRow(it models.Item, csrf string, history []models.PricePoint) {
<tr id={ fmt.Sprintf("item-row-%d", it.ID) }> <tr id={ fmt.Sprintf("item-row-%d", it.ID) }>
<td> <td>
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID)) }>{ it.Name }</a> <div class="flex items-center gap-2">
if isDeal(it) {
<img class="v-deal-mascot" src="/static/img/veola.avif" alt="" title={ fmt.Sprintf("Deal! Best %s ≤ target %s", fmtPrice(it.BestPrice, it.BestPriceCurrency), fmtPrice(it.TargetPrice, "USD")) }/>
}
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID)) }>{ it.Name }</a>
</div>
if it.LastPollError != "" { if it.LastPollError != "" {
<button class="v-pill v-pill-error ml-2" hx-get={ fmt.Sprintf("/items/%d/error", it.ID) } hx-target={ fmt.Sprintf("#item-error-%d", it.ID) } hx-swap="innerHTML">!</button> <button class="v-pill v-pill-error ml-2" hx-get={ fmt.Sprintf("/items/%d/error", it.ID) } hx-target={ fmt.Sprintf("#item-error-%d", it.ID) } hx-swap="innerHTML">!</button>
<div id={ fmt.Sprintf("item-error-%d", it.ID) } class="v-error-text mt-1"></div> <div id={ fmt.Sprintf("item-error-%d", it.ID) } class="v-error-text mt-1"></div>
@@ -89,7 +176,12 @@ templ itemRow(it models.Item, csrf string) {
</td> </td>
<td> <td>
if it.BestPrice != nil { if it.BestPrice != nil {
<div class={ "font-mono text-lg", priceClass(it.BestPrice, it.TargetPrice) }>{ fmtPrice(it.BestPrice, "USD") }</div> <div class="flex items-center gap-1.5">
<span class={ "font-mono text-lg", priceClass(it.BestPrice, it.TargetPrice) }>{ fmtPrice(it.BestPrice, it.BestPriceCurrency) }</span>
if glyph, cls := trendArrow(history); glyph != "" {
<span class={ "v-trend", cls }>{ glyph }</span>
}
</div>
if it.BestPriceURL != "" { if it.BestPriceURL != "" {
<a class="text-xs" href={ templ.SafeURL(it.BestPriceURL) } target="_blank" rel="noopener">{ it.BestPriceStore }</a> <a class="text-xs" href={ templ.SafeURL(it.BestPriceURL) } target="_blank" rel="noopener">{ it.BestPriceStore }</a>
} else if it.BestPriceStore != "" { } else if it.BestPriceStore != "" {
@@ -99,6 +191,15 @@ templ itemRow(it models.Item, csrf string) {
<span class="v-muted">not yet</span> <span class="v-muted">not yet</span>
} }
</td> </td>
<td>
if pts := sparklinePoints(history); pts != "" {
<svg class={ "v-sparkline", sparklineTrendClass(history) } viewBox="0 0 80 24" width="80" height="24" aria-hidden="true">
<polyline points={ pts } fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
} else {
<span class="v-muted text-xs">—</span>
}
</td>
<td class="v-muted text-sm"> <td class="v-muted text-sm">
if it.LastPolledAt != nil { if it.LastPolledAt != nil {
<span title={ it.LastPolledAt.Format("2006-01-02 15:04:05") }>{ humanTime(*it.LastPolledAt) }</span> <span title={ it.LastPolledAt.Format("2006-01-02 15:04:05") }>{ humanTime(*it.LastPolledAt) }</span>
@@ -154,9 +255,11 @@ templ Items(d ItemsData) {
@Layout(d.Page, itemsBody(d)) @Layout(d.Page, itemsBody(d))
} }
// ItemRow renders a single row partial, used by HTMX endpoints. // ItemRow renders a single row partial, used by HTMX endpoints. Callers
templ ItemRow(it models.Item, csrf string) { // that don't have history cheaply on hand pass nil; the sparkline cell
@itemRow(it, csrf) // degrades to an em-dash placeholder.
templ ItemRow(it models.Item, csrf string, history []models.PricePoint) {
@itemRow(it, csrf, history)
} }
// EmptyRow lets a delete handler return a row replacement that vanishes. // EmptyRow lets a delete handler return a row replacement that vanishes.

View File

@@ -10,6 +10,8 @@ import templruntime "github.com/a-h/templ/runtime"
import ( import (
"fmt" "fmt"
"math"
"strings"
"veola/internal/models" "veola/internal/models"
) )
@@ -19,6 +21,85 @@ type ItemsData struct {
Items []models.Item Items []models.Item
Categories []string Categories []string
SelectedCategory string SelectedCategory string
// PriceHistory holds the last ~20 points per item id, ascending in time.
// Nil/missing entries render an empty sparkline cell.
PriceHistory map[int64][]models.PricePoint
}
// sparklinePoints turns a price-history slice into an SVG polyline "points"
// attribute, normalized to a 80x24 viewBox with 2px padding so endpoints
// don't clip the stroke. Returns "" when there isn't enough history to draw.
func sparklinePoints(history []models.PricePoint) string {
if len(history) < 2 {
return ""
}
const w, h, pad = 80.0, 24.0, 2.0
minP, maxP := math.Inf(1), math.Inf(-1)
for _, p := range history {
if p.Price < minP {
minP = p.Price
}
if p.Price > maxP {
maxP = p.Price
}
}
span := maxP - minP
if span == 0 {
// All-equal series: draw a flat line through the middle.
span = 1
}
step := (w - 2*pad) / float64(len(history)-1)
parts := make([]string, len(history))
for i, p := range history {
x := pad + float64(i)*step
y := h - pad - ((p.Price-minP)/span)*(h-2*pad)
parts[i] = fmt.Sprintf("%.1f,%.1f", x, y)
}
return strings.Join(parts, " ")
}
// sparklineTrendClass compares the last point to the average of the prior
// points and returns a CSS class so the sparkline tints green (price down),
// red (up), or neutral. Threshold is ±3% to ignore tiny wobbles.
func sparklineTrendClass(history []models.PricePoint) string {
if len(history) < 2 {
return ""
}
last := history[len(history)-1].Price
var sum float64
for _, p := range history[:len(history)-1] {
sum += p.Price
}
avg := sum / float64(len(history)-1)
if avg == 0 {
return "v-spark-flat"
}
switch {
case last <= avg*0.97:
return "v-spark-down"
case last >= avg*1.03:
return "v-spark-up"
}
return "v-spark-flat"
}
// trendArrow returns the unicode arrow + a CSS class describing the direction.
// Same threshold logic as the sparkline. Empty when there's no trend signal.
func trendArrow(history []models.PricePoint) (glyph, class string) {
switch sparklineTrendClass(history) {
case "v-spark-down":
return "↓", "v-trend-down"
case "v-spark-up":
return "↑", "v-trend-up"
case "v-spark-flat":
return "→", "v-trend-flat"
}
return "", ""
}
// isDeal is the gate for the mascot "deal" moment on a row.
func isDeal(it models.Item) bool {
return it.BestPrice != nil && it.TargetPrice != nil && *it.BestPrice <= *it.TargetPrice
} }
func itemsBody(d ItemsData) templ.Component { func itemsBody(d ItemsData) templ.Component {
@@ -59,7 +140,7 @@ func itemsBody(d ItemsData) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(c) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(c)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 28, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 109, Col: 23}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -82,7 +163,7 @@ func itemsBody(d ItemsData) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(c) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(c)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 28, Col: 67} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 109, Col: 67}
} }
_, 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 {
@@ -104,12 +185,12 @@ func itemsBody(d ItemsData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"v-card p-0 overflow-hidden\"><table class=\"v-table\"><thead><tr><th>Name</th><th>Category</th><th>Target</th><th>Best Price</th><th>Last Polled</th><th>Status</th><th></th></tr></thead> <tbody id=\"items-tbody\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"v-card p-0 overflow-hidden\"><table class=\"v-table\"><thead><tr><th>Name</th><th>Category</th><th>Target</th><th>Best Price</th><th>Trend</th><th>Last Polled</th><th>Status</th><th></th></tr></thead> <tbody id=\"items-tbody\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, it := range d.Items { for _, it := range d.Items {
templ_7745c5c3_Err = itemRow(it, d.CSRFToken).Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = itemRow(it, d.CSRFToken, d.PriceHistory[it.ID]).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -148,7 +229,7 @@ func itemsEmpty() templ.Component {
templ_7745c5c3_Var4 = templ.NopComponent templ_7745c5c3_Var4 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"v-card p-8 flex flex-col md:flex-row items-center gap-6\"><div class=\"v-veola-portrait w-48 shrink-0\"><img src=\"/static/img/veola.webp\" alt=\"Veola\"></div><div><h2 class=\"text-xl font-semibold mb-2\">Nothing on the watchlist.</h2><p class=\"v-muted mb-4\">Add an item and Veola will keep an eye on it.</p><a class=\"v-btn\" href=\"/items/new\">Add the first item</a></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"v-card p-8 flex flex-col md:flex-row items-center gap-6\"><div class=\"v-veola-portrait w-48 shrink-0\"><img src=\"/static/img/veola.avif\" alt=\"Veola\"></div><div><h2 class=\"text-xl font-semibold mb-2\">Nothing on the watchlist.</h2><p class=\"v-muted mb-4\">Add an item and Veola will keep an eye on it.</p><a class=\"v-btn\" href=\"/items/new\">Add the first item</a></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -156,7 +237,7 @@ func itemsEmpty() templ.Component {
}) })
} }
func itemRow(it models.Item, csrf string) templ.Component { func itemRow(it models.Item, csrf string, history []models.PricePoint) 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 {
@@ -184,418 +265,532 @@ func itemRow(it models.Item, csrf string) templ.Component {
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("item-row-%d", it.ID)) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("item-row-%d", it.ID))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 74, Col: 43} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 156, Col: 43}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 14, "\"><td><a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"><td><div class=\"flex items-center gap-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 templ.SafeURL if isDeal(it) {
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID))) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<img class=\"v-deal-mascot\" src=\"/static/img/veola.avif\" alt=\"\" title=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 76, Col: 67} return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("Deal! Best %s ≤ target %s", fmtPrice(it.BestPrice, it.BestPriceCurrency), fmtPrice(it.TargetPrice, "USD")))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 160, Col: 197}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a href=\"")
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, 15, "\">") var templ_7745c5c3_Var8 templ.SafeURL
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 162, Col: 68}
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(it.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 76, Col: 79}
} }
_, 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, 16, "</a> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(it.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 162, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if it.LastPollError != "" { if it.LastPollError != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<button class=\"v-pill v-pill-error ml-2\" hx-get=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<button class=\"v-pill v-pill-error ml-2\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/error", it.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 78, Col: 91}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" hx-target=\"")
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.ResolveAttributeValue(fmt.Sprintf("#item-error-%d", it.ID)) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/error", it.ID))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 78, Col: 142} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 165, Col: 91}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 19, "\" hx-swap=\"innerHTML\">!</button><div id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" hx-target=\"")
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.ResolveAttributeValue(fmt.Sprintf("item-error-%d", it.ID)) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-error-%d", it.ID))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 79, Col: 49} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 165, Col: 142}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 20, "\" class=\"v-error-text mt-1\"></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" hx-swap=\"innerHTML\">!</button><div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("item-error-%d", it.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 166, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" class=\"v-error-text mt-1\"></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, 21, "</td><td class=\"v-muted\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td><td class=\"v-muted\">")
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_Var13 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(it.Category) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(it.Category)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 82, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 169, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, 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, 22, "</td><td class=\"font-mono\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</td><td class=\"font-mono\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if it.TargetPrice != nil { if it.TargetPrice != nil {
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.TargetPrice, "USD")) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.TargetPrice, "USD"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 85, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 172, Col: 37}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"v-muted\">—</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span class=\"v-muted\">—</span>")
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, "</td><td>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</td><td>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if it.BestPrice != nil { if it.BestPrice != nil {
var templ_7745c5c3_Var14 = []any{"font-mono text-lg", priceClass(it.BestPrice, it.TargetPrice)} templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"flex items-center gap-1.5\">")
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, 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, "<div class=\"") var templ_7745c5c3_Var15 = []any{"font-mono text-lg", priceClass(it.BestPrice, it.TargetPrice)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span class=\"")
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var14).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.BestPrice, "USD")) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var15).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 92, Col: 112} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 27, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if it.BestPriceURL != "" { var templ_7745c5c3_Var17 string
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<a class=\"text-xs\" href=\"") templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.BestPrice, it.BestPriceCurrency))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 180, Col: 129}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if glyph, cls := trendArrow(history); glyph != "" {
var templ_7745c5c3_Var18 = []any{"v-trend", cls}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 templ.SafeURL templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span class=\"")
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(it.BestPriceURL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 94, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" target=\"_blank\" rel=\"noopener\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(it.BestPriceStore)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 94, Col: 114}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if it.BestPriceStore != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<span class=\"text-xs v-muted\">")
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(it.BestPriceStore) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var18).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 96, Col: 54} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 32, "</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(glyph)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 182, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if it.BestPriceURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<a class=\"text-xs\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 templ.SafeURL
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(it.BestPriceURL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 186, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" target=\"_blank\" rel=\"noopener\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(it.BestPriceStore)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 186, Col: 114}
}
_, 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, 38, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if it.BestPriceStore != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span class=\"text-xs v-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(it.BestPriceStore)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 188, Col: 54}
}
_, 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, 40, "</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"v-muted\">not yet</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<span class=\"v-muted\">not yet</span>")
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 class=\"v-muted text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if pts := sparklinePoints(history); pts != "" {
var templ_7745c5c3_Var24 = []any{"v-sparkline", sparklineTrendClass(history)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var24...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var24).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" viewBox=\"0 0 80 24\" width=\"80\" height=\"24\" aria-hidden=\"true\"><polyline points=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(pts)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 197, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"></polyline></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<span class=\"v-muted text-xs\">—</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</td><td class=\"v-muted text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if it.LastPolledAt != nil { if it.LastPolledAt != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span title=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<span title=\"")
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_Var27 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(it.LastPolledAt.Format("2006-01-02 15:04:05")) templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(it.LastPolledAt.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 104, Col: 63} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 205, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
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, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\">")
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_Var28 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(*it.LastPolledAt)) templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(*it.LastPolledAt))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 104, Col: 95} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 205, Col: 95}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
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, 37, "</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "—") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "—")
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, 39, "</td><td>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</td><td>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if it.Active { if it.Active {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<span class=\"v-pill v-pill-active\">active</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<span class=\"v-pill v-pill-active\">active</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<span class=\"v-pill v-pill-paused\">paused</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span class=\"v-pill v-pill-paused\">paused</span>")
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, "</td><td class=\"text-right whitespace-nowrap\"><form class=\"inline\" hx-post=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</td><td class=\"text-right whitespace-nowrap\"><form class=\"inline\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/toggle", it.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 117, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-row-%d", it.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 117, Col: 121}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 118, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\"> <button class=\"v-btn-ghost text-sm\" type=\"submit\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if it.Active {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "Pause")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "Resume")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</button></form><form class=\"inline\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/run", it.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 127, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-row-%d", it.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 127, Col: 118}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" hx-swap=\"outerHTML\" hx-disabled-elt=\"find button\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 128, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\"> <button class=\"v-btn-ghost text-sm\" type=\"submit\">Run Now <span class=\"v-spinner htmx-indicator ml-1\"></span></button></form><a class=\"v-btn-ghost text-sm\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 templ.SafeURL
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/edit", it.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 134, Col: 92}
}
_, 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, 52, "\">Edit</a><form class=\"inline\" hx-post=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var29 string var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/delete", it.ID)) templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/toggle", it.ID))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 135, Col: 72} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 218, Col: 72}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
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, 53, "\" hx-target=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" hx-target=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var30 string var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-row-%d", it.ID)) templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-row-%d", it.ID))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 135, Col: 121} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 218, Col: 121}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
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, 54, "\" hx-swap=\"outerHTML\" hx-confirm=\"Delete this item?\"><input type=\"hidden\" name=\"csrf_token\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var31 string var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf) templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 136, Col: 55} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 219, Col: 55}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
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, 55, "\"> <button class=\"v-btn-ghost text-sm\" type=\"submit\">Delete</button></form></td></tr>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"> <button class=\"v-btn-ghost text-sm\" type=\"submit\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if it.Active {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "Pause")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "Resume")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</button></form><form class=\"inline\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/run", it.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 228, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-row-%d", it.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 228, Col: 118}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" hx-swap=\"outerHTML\" hx-disabled-elt=\"find button\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 229, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\"> <button class=\"v-btn-ghost text-sm\" type=\"submit\">Run Now <span class=\"v-spinner htmx-indicator ml-1\"></span></button></form><a class=\"v-btn-ghost text-sm\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 templ.SafeURL
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/edit", it.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 235, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\">Edit</a><form class=\"inline\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/delete", it.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 236, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-row-%d", it.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 236, Col: 121}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" hx-swap=\"outerHTML\" hx-confirm=\"Delete this item?\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 237, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\"> <button class=\"v-btn-ghost text-sm\" type=\"submit\">Delete</button></form></td></tr>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -629,9 +824,9 @@ func Items(d ItemsData) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var32 := templ.GetChildren(ctx) templ_7745c5c3_Var39 := templ.GetChildren(ctx)
if templ_7745c5c3_Var32 == nil { if templ_7745c5c3_Var39 == nil {
templ_7745c5c3_Var32 = templ.NopComponent templ_7745c5c3_Var39 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Layout(d.Page, itemsBody(d)).Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Layout(d.Page, itemsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
@@ -642,8 +837,10 @@ func Items(d ItemsData) templ.Component {
}) })
} }
// ItemRow renders a single row partial, used by HTMX endpoints. // ItemRow renders a single row partial, used by HTMX endpoints. Callers
func ItemRow(it models.Item, csrf string) templ.Component { // that don't have history cheaply on hand pass nil; the sparkline cell
// degrades to an em-dash placeholder.
func ItemRow(it models.Item, csrf string, history []models.PricePoint) 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 {
@@ -659,12 +856,12 @@ func ItemRow(it models.Item, csrf string) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var33 := templ.GetChildren(ctx) templ_7745c5c3_Var40 := templ.GetChildren(ctx)
if templ_7745c5c3_Var33 == nil { if templ_7745c5c3_Var40 == nil {
templ_7745c5c3_Var33 = templ.NopComponent templ_7745c5c3_Var40 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = itemRow(it, csrf).Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = itemRow(it, csrf, history).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -689,12 +886,12 @@ func EmptyRow() templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var34 := templ.GetChildren(ctx) templ_7745c5c3_Var41 := templ.GetChildren(ctx)
if templ_7745c5c3_Var34 == nil { if templ_7745c5c3_Var41 == nil {
templ_7745c5c3_Var34 = templ.NopComponent templ_7745c5c3_Var41 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<tr></tr>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<tr></tr>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }

View File

@@ -23,16 +23,20 @@ templ head(title string) {
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet"/> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/css/tailwind.css"/> <link rel="stylesheet" href="/static/css/tailwind.css"/>
<link rel="stylesheet" href="/static/css/app.css"/> <link rel="stylesheet" href="/static/css/app.css"/>
<!-- retro.css is an additive flair layer. Delete this <link> + the
file to revert to the plainer look. See retro.css for details. -->
<link rel="stylesheet" href="/static/css/retro.css"/>
<script src="/static/vendor/htmx.min.js" defer></script> <script src="/static/vendor/htmx.min.js" defer></script>
<script src="/static/js/flair.js" defer></script>
</head> </head>
} }
templ Sidebar(active string) { templ Sidebar(active string) {
<nav class="v-side-nav flex flex-col"> <nav class="v-side-nav flex flex-col">
<div class="px-4 py-5 flex items-center gap-2"> <a href="/" class="v-side-brand px-4 py-5 flex items-center gap-2">
<span class="text-2xl">🐝</span> <span class="text-2xl">🐝</span>
<span class="text-xl font-semibold tracking-wide">Veola</span> <span class="text-xl font-semibold tracking-wide">Veola</span>
</div> </a>
<a href="/" class={ navClass("dashboard", active) }>Dashboard</a> <a href="/" class={ navClass("dashboard", active) }>Dashboard</a>
<a href="/items" class={ navClass("items", active) }>Items</a> <a href="/items" class={ navClass("items", active) }>Items</a>
<a href="/results" class={ navClass("results", active) }>Results</a> <a href="/results" class={ navClass("results", active) }>Results</a>

View File

@@ -55,7 +55,7 @@ func head(title string) templ.Component {
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, 2, " · Veola</title><link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin><link href=\"https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap\" rel=\"stylesheet\"><link rel=\"stylesheet\" href=\"/static/css/tailwind.css\"><link rel=\"stylesheet\" href=\"/static/css/app.css\"><script src=\"/static/vendor/htmx.min.js\" defer></script></head>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Veola</title><link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin><link href=\"https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap\" rel=\"stylesheet\"><link rel=\"stylesheet\" href=\"/static/css/tailwind.css\"><link rel=\"stylesheet\" href=\"/static/css/app.css\"><!-- retro.css is an additive flair layer. Delete this <link> + the\n\t\t file to revert to the plainer look. See retro.css for details. --><link rel=\"stylesheet\" href=\"/static/css/retro.css\"><script src=\"/static/vendor/htmx.min.js\" defer></script><script src=\"/static/js/flair.js\" defer></script></head>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -84,7 +84,7 @@ func Sidebar(active string) templ.Component {
templ_7745c5c3_Var3 = templ.NopComponent templ_7745c5c3_Var3 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<nav class=\"v-side-nav flex flex-col\"><div class=\"px-4 py-5 flex items-center gap-2\"><span class=\"text-2xl\">🐝</span> <span class=\"text-xl font-semibold tracking-wide\">Veola</span></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<nav class=\"v-side-nav flex flex-col\"><a href=\"/\" class=\"v-side-brand px-4 py-5 flex items-center gap-2\"><span class=\"text-2xl\">🐝</span> <span class=\"text-xl font-semibold tracking-wide\">Veola</span></a> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -236,7 +236,7 @@ func Layout(p Page, body templ.Component) templ.Component {
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(p.Flash) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(p.Flash)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 61, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 65, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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 {
@@ -255,7 +255,7 @@ func Layout(p Page, body templ.Component) templ.Component {
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.FlashError) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.FlashError)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 64, Col: 46} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 68, Col: 46}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -353,7 +353,7 @@ func CSRFInput(token string) templ.Component {
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(token) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(token)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 85, Col: 53} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 89, Col: 53}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {

View File

@@ -7,35 +7,40 @@ type LoginData struct {
} }
templ loginBody(d LoginData) { templ loginBody(d LoginData) {
<div class="min-h-screen grid md:grid-cols-2"> <div class="min-h-screen grid md:grid-cols-2 v-auth-grid">
<div class="flex items-center justify-center p-8"> <div class="flex items-center justify-center p-8">
<div class="w-full max-w-sm"> <div class="w-full max-w-sm">
<div class="flex items-center gap-2 mb-6"> <div class="flex items-center gap-2 mb-8 v-auth-wordmark">
<span class="text-3xl">🐝</span> <span class="text-3xl">🐝</span>
<span class="text-2xl font-semibold tracking-wide">Veola</span> <span class="text-2xl font-semibold tracking-wide">Veola</span>
</div> </div>
<h1 class="text-3xl font-semibold mb-2">Open the door.</h1> <div class="v-card v-auth-card p-7">
<p class="v-muted mb-6">Sign in to continue.</p> <h1 class="text-3xl font-semibold mb-2">Open the door.</h1>
if d.Error != "" { <p class="v-muted mb-6">Sign in to continue.</p>
<div class="v-flash-error">{ d.Error }</div> if d.Error != "" {
} <div class="v-flash-error">{ d.Error }</div>
<form method="post" action="/login" class="space-y-4"> }
@CSRFInput(d.CSRFToken) <form method="post" action="/login" class="space-y-4">
<div> @CSRFInput(d.CSRFToken)
<label class="v-label">Username</label> <div>
<input class="v-input" name="username" autocomplete="username" autofocus value={ d.Username }/> <label class="v-label">Username</label>
</div> <input class="v-input" name="username" autocomplete="username" autofocus value={ d.Username }/>
<div> </div>
<label class="v-label">Password</label> <div>
<input class="v-input" type="password" name="password" autocomplete="current-password"/> <label class="v-label">Password</label>
</div> <input class="v-input" type="password" name="password" autocomplete="current-password"/>
<button class="v-btn w-full justify-center" type="submit">Sign In</button> </div>
</form> <button class="v-btn w-full justify-center" type="submit">Sign In</button>
</form>
</div>
<p class="v-auth-tagline">Track · Watch · Notice</p>
</div> </div>
</div> </div>
<div class="hidden md:flex items-end justify-center p-8 bg-[#152560]"> <div class="hidden md:flex flex-col items-center justify-center p-8 v-auth-portrait-col">
<div class="v-veola-portrait max-w-md"> <div class="v-auth-portrait-halo">
<img src="/static/img/veola.webp" alt="Veola"/> <div class="v-veola-portrait max-w-md">
<img src="/static/img/veola.avif" alt="Veola"/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -52,39 +57,44 @@ type SetupData struct {
} }
templ setupBody(d SetupData) { templ setupBody(d SetupData) {
<div class="min-h-screen grid md:grid-cols-2"> <div class="min-h-screen grid md:grid-cols-2 v-auth-grid">
<div class="flex items-center justify-center p-8"> <div class="flex items-center justify-center p-8">
<div class="w-full max-w-sm"> <div class="w-full max-w-sm">
<div class="flex items-center gap-2 mb-6"> <div class="flex items-center gap-2 mb-8 v-auth-wordmark">
<span class="text-3xl">🐝</span> <span class="text-3xl">🐝</span>
<span class="text-2xl font-semibold tracking-wide">Veola</span> <span class="text-2xl font-semibold tracking-wide">Veola</span>
</div> </div>
<h1 class="text-3xl font-semibold mb-2">First time here.</h1> <div class="v-card v-auth-card p-7">
<p class="v-muted mb-6">Create the admin account. Password must be at least 12 characters.</p> <h1 class="text-3xl font-semibold mb-2">First time here.</h1>
if d.Error != "" { <p class="v-muted mb-6">Create the admin account. Password must be at least 12 characters.</p>
<div class="v-flash-error">{ d.Error }</div> if d.Error != "" {
} <div class="v-flash-error">{ d.Error }</div>
<form method="post" action="/setup" class="space-y-4"> }
@CSRFInput(d.CSRFToken) <form method="post" action="/setup" class="space-y-4">
<div> @CSRFInput(d.CSRFToken)
<label class="v-label">Username</label> <div>
<input class="v-input" name="username" autofocus value={ d.Username }/> <label class="v-label">Username</label>
</div> <input class="v-input" name="username" autofocus value={ d.Username }/>
<div> </div>
<label class="v-label">Password</label> <div>
<input class="v-input" type="password" name="password"/> <label class="v-label">Password</label>
</div> <input class="v-input" type="password" name="password"/>
<div> </div>
<label class="v-label">Confirm Password</label> <div>
<input class="v-input" type="password" name="password_confirm"/> <label class="v-label">Confirm Password</label>
</div> <input class="v-input" type="password" name="password_confirm"/>
<button class="v-btn w-full justify-center" type="submit">Create Admin</button> </div>
</form> <button class="v-btn w-full justify-center" type="submit">Create Admin</button>
</form>
</div>
<p class="v-auth-tagline">Track · Watch · Notice</p>
</div> </div>
</div> </div>
<div class="hidden md:flex items-end justify-center p-8 bg-[#152560]"> <div class="hidden md:flex flex-col items-center justify-center p-8 v-auth-portrait-col">
<div class="v-veola-portrait max-w-md"> <div class="v-auth-portrait-halo">
<img src="/static/img/veola.webp" alt="Veola"/> <div class="v-veola-portrait max-w-md">
<img src="/static/img/veola.avif" alt="Veola"/>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -35,7 +35,7 @@ func loginBody(d LoginData) 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 class=\"min-h-screen grid md:grid-cols-2\"><div class=\"flex items-center justify-center p-8\"><div class=\"w-full max-w-sm\"><div class=\"flex items-center gap-2 mb-6\"><span class=\"text-3xl\">🐝</span> <span class=\"text-2xl font-semibold tracking-wide\">Veola</span></div><h1 class=\"text-3xl font-semibold mb-2\">Open the door.</h1><p class=\"v-muted mb-6\">Sign in to continue.</p>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"min-h-screen grid md:grid-cols-2 v-auth-grid\"><div class=\"flex items-center justify-center p-8\"><div class=\"w-full max-w-sm\"><div class=\"flex items-center gap-2 mb-8 v-auth-wordmark\"><span class=\"text-3xl\">🐝</span> <span class=\"text-2xl font-semibold tracking-wide\">Veola</span></div><div class=\"v-card v-auth-card p-7\"><h1 class=\"text-3xl font-semibold mb-2\">Open the door.</h1><p class=\"v-muted mb-6\">Sign in to continue.</p>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -47,7 +47,7 @@ func loginBody(d LoginData) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 20, Col: 41} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 21, Col: 42}
} }
_, 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 {
@@ -73,13 +73,13 @@ func loginBody(d LoginData) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Username) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 26, Col: 97} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 27, Col: 98}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 6, "\"></div><div><label class=\"v-label\">Password</label> <input class=\"v-input\" type=\"password\" name=\"password\" autocomplete=\"current-password\"></div><button class=\"v-btn w-full justify-center\" type=\"submit\">Sign In</button></form></div></div><div class=\"hidden md:flex items-end justify-center p-8 bg-[#152560]\"><div class=\"v-veola-portrait max-w-md\"><img src=\"/static/img/veola.webp\" alt=\"Veola\"></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"></div><div><label class=\"v-label\">Password</label> <input class=\"v-input\" type=\"password\" name=\"password\" autocomplete=\"current-password\"></div><button class=\"v-btn w-full justify-center\" type=\"submit\">Sign In</button></form></div><p class=\"v-auth-tagline\">Track · Watch · Notice</p></div></div><div class=\"hidden md:flex flex-col items-center justify-center p-8 v-auth-portrait-col\"><div class=\"v-auth-portrait-halo\"><div class=\"v-veola-portrait max-w-md\"><img src=\"/static/img/veola.avif\" alt=\"Veola\"></div></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -143,7 +143,7 @@ func setupBody(d SetupData) templ.Component {
templ_7745c5c3_Var5 = templ.NopComponent templ_7745c5c3_Var5 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"min-h-screen grid md:grid-cols-2\"><div class=\"flex items-center justify-center p-8\"><div class=\"w-full max-w-sm\"><div class=\"flex items-center gap-2 mb-6\"><span class=\"text-3xl\">🐝</span> <span class=\"text-2xl font-semibold tracking-wide\">Veola</span></div><h1 class=\"text-3xl font-semibold mb-2\">First time here.</h1><p class=\"v-muted mb-6\">Create the admin account. Password must be at least 12 characters.</p>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"min-h-screen grid md:grid-cols-2 v-auth-grid\"><div class=\"flex items-center justify-center p-8\"><div class=\"w-full max-w-sm\"><div class=\"flex items-center gap-2 mb-8 v-auth-wordmark\"><span class=\"text-3xl\">🐝</span> <span class=\"text-2xl font-semibold tracking-wide\">Veola</span></div><div class=\"v-card v-auth-card p-7\"><h1 class=\"text-3xl font-semibold mb-2\">First time here.</h1><p class=\"v-muted mb-6\">Create the admin account. Password must be at least 12 characters.</p>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -155,7 +155,7 @@ func setupBody(d SetupData) templ.Component {
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 65, Col: 41} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 71, Col: 42}
} }
_, 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 {
@@ -181,13 +181,13 @@ func setupBody(d SetupData) templ.Component {
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Username) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 71, Col: 73} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 77, Col: 74}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 12, "\"></div><div><label class=\"v-label\">Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><div><label class=\"v-label\">Confirm Password</label> <input class=\"v-input\" type=\"password\" name=\"password_confirm\"></div><button class=\"v-btn w-full justify-center\" type=\"submit\">Create Admin</button></form></div></div><div class=\"hidden md:flex items-end justify-center p-8 bg-[#152560]\"><div class=\"v-veola-portrait max-w-md\"><img src=\"/static/img/veola.webp\" alt=\"Veola\"></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"></div><div><label class=\"v-label\">Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><div><label class=\"v-label\">Confirm Password</label> <input class=\"v-input\" type=\"password\" name=\"password_confirm\"></div><button class=\"v-btn w-full justify-center\" type=\"submit\">Create Admin</button></form></div><p class=\"v-auth-tagline\">Track · Watch · Notice</p></div></div><div class=\"hidden md:flex flex-col items-center justify-center p-8 v-auth-portrait-col\"><div class=\"v-auth-portrait-halo\"><div class=\"v-veola-portrait max-w-md\"><img src=\"/static/img/veola.avif\" alt=\"Veola\"></div></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }

View File

@@ -3,7 +3,9 @@ package templates
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"time"
"veola/internal/db"
"veola/internal/models" "veola/internal/models"
) )
@@ -21,6 +23,9 @@ type ItemResultsData struct {
// normal page load; PostRunItem sets exactly one. // normal page load; PostRunItem sets exactly one.
RunMsg string RunMsg string
RunError string RunError string
// EndingSoon, when non-nil, surfaces the soonest-ending auction across this
// item's results so users can act before close.
EndingSoon *db.EndingSoon
} }
type BadgeData struct { type BadgeData struct {
@@ -35,6 +40,8 @@ type GlobalResultsData struct {
ItemID int64 ItemID int64
From string From string
To string To string
// EndingSoon mirrors the per-item field but spans every watched item.
EndingSoon *db.EndingSoon
} }
type ItemResultRow struct { type ItemResultRow struct {
@@ -42,8 +49,40 @@ type ItemResultRow struct {
ItemName string ItemName string
} }
// endingSoonStrip surfaces the next auction about to close. Hidden when nil
// (the handler decides cutoff: 24h by default). The data-ends-at attribute
// drives the live countdown in flair.js.
templ endingSoonStrip(e *db.EndingSoon) {
if e != nil {
<div class="v-ending-strip mb-5" data-ends-at={ e.EndsAt.UTC().Format(time.RFC3339) }>
<div class="v-ending-label">Ending soon</div>
<div class="v-ending-title">
if e.URL != "" {
<a href={ templ.SafeURL(e.URL) } target="_blank" rel="noopener">{ e.Title }</a>
} else {
{ e.Title }
}
<span class="v-muted text-xs ml-2">{ e.ItemName }</span>
</div>
<div class="v-ending-countdown font-mono" data-countdown></div>
</div>
}
}
// endsInCell renders the per-row countdown for auction listings only. Fixed-
// price rows (EndsAt == nil) get an em-dash placeholder so column widths stay
// consistent.
templ endsInCell(endsAt *time.Time) {
if endsAt != nil {
<span class="v-countdown font-mono text-sm" data-ends-at={ endsAt.UTC().Format(time.RFC3339) } data-countdown></span>
} else {
<span class="v-muted">—</span>
}
}
templ itemResultsBody(d ItemResultsData) { templ itemResultsBody(d ItemResultsData) {
<div> <div>
@endingSoonStrip(d.EndingSoon)
<div class="flex items-start justify-between mb-4"> <div class="flex items-start justify-between mb-4">
<div> <div>
<h1 class="text-3xl font-semibold">{ d.Item.Name }</h1> <h1 class="text-3xl font-semibold">{ d.Item.Name }</h1>
@@ -53,7 +92,7 @@ templ itemResultsBody(d ItemResultsData) {
</div> </div>
<div class="text-right"> <div class="text-right">
if d.Item.BestPrice != nil { if d.Item.BestPrice != nil {
<div class="font-mono text-3xl">{ fmtPrice(d.Item.BestPrice, "USD") }</div> <div class="font-mono text-3xl">{ fmtPrice(d.Item.BestPrice, d.Item.BestPriceCurrency) }</div>
if d.Item.BestPriceURL != "" { if d.Item.BestPriceURL != "" {
<a class="text-sm" href={ templ.SafeURL(d.Item.BestPriceURL) } target="_blank" rel="noopener">{ d.Item.BestPriceStore }</a> <a class="text-sm" href={ templ.SafeURL(d.Item.BestPriceURL) } target="_blank" rel="noopener">{ d.Item.BestPriceStore }</a>
} }
@@ -89,7 +128,9 @@ templ itemResultsBody(d ItemResultsData) {
// Chart data rides on a data- attribute, not <script> content: // Chart data rides on a data- attribute, not <script> content:
// templ interpolates (and escapes) attribute values but treats // templ interpolates (and escapes) attribute values but treats
// <script> bodies as raw text, leaving { expr } literal. // <script> bodies as raw text, leaving { expr } literal.
<canvas id="price-chart" height="120" data-chart={ d.HistoryChartJSON }></canvas> <div class="relative h-64 w-full">
<canvas id="price-chart" data-chart={ d.HistoryChartJSON }></canvas>
</div>
<script src="/static/vendor/chart.umd.min.js"></script> <script src="/static/vendor/chart.umd.min.js"></script>
<script src="/static/js/price-chart.js"></script> <script src="/static/js/price-chart.js"></script>
} }
@@ -120,6 +161,7 @@ templ ItemResultsTable(d ItemResultsData) {
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "price"))) }>Price</a> <a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "price"))) }>Price</a>
</th> </th>
<th>Store</th> <th>Store</th>
<th>Ends</th>
<th> <th>
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "found"))) }>Found</a> <a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "found"))) }>Found</a>
</th> </th>
@@ -146,6 +188,7 @@ templ ItemResultsTable(d ItemResultsData) {
</td> </td>
<td class={ "font-mono", priceClass(r.Price, d.Item.TargetPrice) }>{ fmtPrice(r.Price, r.Currency) }</td> <td class={ "font-mono", priceClass(r.Price, d.Item.TargetPrice) }>{ fmtPrice(r.Price, r.Currency) }</td>
<td class="v-muted">{ r.Source }</td> <td class="v-muted">{ r.Source }</td>
<td>@endsInCell(r.EndsAt)</td>
<td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td> <td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td>
<td> <td>
if r.Alerted { if r.Alerted {
@@ -197,6 +240,7 @@ templ ItemResults(d ItemResultsData) {
templ globalResultsBody(d GlobalResultsData) { templ globalResultsBody(d GlobalResultsData) {
<div> <div>
<h1 class="text-3xl font-semibold mb-6">All Results</h1> <h1 class="text-3xl font-semibold mb-6">All Results</h1>
@endingSoonStrip(d.EndingSoon)
<form method="get" action="/results" class="flex items-end gap-3 mb-4"> <form method="get" action="/results" class="flex items-end gap-3 mb-4">
<div> <div>
<label class="v-label">Item</label> <label class="v-label">Item</label>
@@ -220,7 +264,7 @@ templ globalResultsBody(d GlobalResultsData) {
<div class="v-card p-0 overflow-hidden"> <div class="v-card p-0 overflow-hidden">
<table class="v-table"> <table class="v-table">
<thead> <thead>
<tr><th>Item</th><th>Title</th><th>Price</th><th>Store</th><th>Found</th><th>Alert</th></tr> <tr><th>Item</th><th>Title</th><th>Price</th><th>Store</th><th>Ends</th><th>Found</th><th>Alert</th></tr>
</thead> </thead>
<tbody> <tbody>
for _, r := range d.Results { for _, r := range d.Results {
@@ -238,6 +282,7 @@ templ globalResultsBody(d GlobalResultsData) {
</td> </td>
<td class="font-mono">{ fmtPrice(r.Price, r.Currency) }</td> <td class="font-mono">{ fmtPrice(r.Price, r.Currency) }</td>
<td class="v-muted">{ r.Source }</td> <td class="v-muted">{ r.Source }</td>
<td>@endsInCell(r.EndsAt)</td>
<td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td> <td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td>
<td> <td>
if r.Alerted { if r.Alerted {

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,12 @@ import (
type SettingsData struct { type SettingsData struct {
Page Page
Values map[string]string Values map[string]string
IsAdmin bool // CredentialStatus maps each secret settings key to a human-readable
// status ("Saved in settings", "Set in config.toml", "Not set"). Secret
// values are never rendered into the form itself.
CredentialStatus map[string]string
IsAdmin bool
Users []models.User Users []models.User
TestNtfyOK string TestNtfyOK string
TestApifyOK string TestApifyOK string
@@ -28,6 +32,16 @@ func (d SettingsData) EbayLimitReached() bool {
return d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit return d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit
} }
// credStatus renders the "Saved in settings / Set in config.toml / Not set"
// indicator for a secret field without ever printing the secret itself.
templ credStatus(d SettingsData, key string) {
if s := d.CredentialStatus[key]; s != "" {
<div class="v-muted text-xs mt-1">
Status: { s }
</div>
}
}
templ settingsBody(d SettingsData) { templ settingsBody(d SettingsData) {
<div class="space-y-8 max-w-3xl"> <div class="space-y-8 max-w-3xl">
<h1 class="text-3xl font-semibold">Settings</h1> <h1 class="text-3xl font-semibold">Settings</h1>
@@ -38,15 +52,18 @@ templ settingsBody(d SettingsData) {
@CSRFInput(d.CSRFToken) @CSRFInput(d.CSRFToken)
<div> <div>
<label class="v-label">Apify API Key</label> <label class="v-label">Apify API Key</label>
<input class="v-input font-mono" type="password" name="apify_api_key" value={ d.Values["apify_api_key"] }/> <input class="v-input font-mono" type="password" name="apify_api_key" autocomplete="off" placeholder="leave blank to keep current value"/>
@credStatus(d, "apify_api_key")
</div> </div>
<div class="border-t border-white/10 pt-4"> <div class="border-t border-white/10 pt-4">
<label class="v-label">eBay App ID (Client ID)</label> <label class="v-label">eBay App ID (Client ID)</label>
<input class="v-input font-mono" type="password" name="ebay_client_id" value={ d.Values["ebay_client_id"] } placeholder="used for eBay marketplaces instead of Apify"/> <input class="v-input font-mono" type="password" name="ebay_client_id" autocomplete="off" placeholder="used for eBay marketplaces instead of Apify"/>
@credStatus(d, "ebay_client_id")
</div> </div>
<div> <div>
<label class="v-label">eBay Cert ID (Client Secret)</label> <label class="v-label">eBay Cert ID (Client Secret)</label>
<input class="v-input font-mono" type="password" name="ebay_client_secret" value={ d.Values["ebay_client_secret"] }/> <input class="v-input font-mono" type="password" name="ebay_client_secret" autocomplete="off" placeholder="leave blank to keep current value"/>
@credStatus(d, "ebay_client_secret")
</div> </div>
<div> <div>
<label class="v-label">eBay Daily Call Limit</label> <label class="v-label">eBay Daily Call Limit</label>
@@ -73,7 +90,8 @@ templ settingsBody(d SettingsData) {
</div> </div>
<div> <div>
<label class="v-label">Ntfy Token</label> <label class="v-label">Ntfy Token</label>
<input class="v-input font-mono" type="password" name="ntfy_token" value={ d.Values["ntfy_token"] } placeholder="tk_... (leave blank if ntfy is unauthenticated)"/> <input class="v-input font-mono" type="password" name="ntfy_token" autocomplete="off" placeholder="tk_... (leave blank to keep current value)"/>
@credStatus(d, "ntfy_token")
</div> </div>
<div> <div>
<label class="v-label">Global Poll Interval (minutes)</label> <label class="v-label">Global Poll Interval (minutes)</label>

View File

@@ -16,18 +16,22 @@ import (
type SettingsData struct { type SettingsData struct {
Page Page
Values map[string]string Values map[string]string
IsAdmin bool // CredentialStatus maps each secret settings key to a human-readable
Users []models.User // status ("Saved in settings", "Set in config.toml", "Not set"). Secret
TestNtfyOK string // values are never rendered into the form itself.
TestApifyOK string CredentialStatus map[string]string
TestEbayOK string IsAdmin bool
EbayUsedToday int Users []models.User
EbayDailyLimit int TestNtfyOK string
PasswordMsg string TestApifyOK string
PasswordError string TestEbayOK string
UserMsg string EbayUsedToday int
UserError string EbayDailyLimit int
PasswordMsg string
PasswordError string
UserMsg string
UserError string
} }
// EbayLimitReached reports whether eBay polling is currently halted because // EbayLimitReached reports whether eBay polling is currently halted because
@@ -36,7 +40,9 @@ func (d SettingsData) EbayLimitReached() bool {
return d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit return d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit
} }
func settingsBody(d SettingsData) templ.Component { // credStatus renders the "Saved in settings / Set in config.toml / Not set"
// indicator for a secret field without ever printing the secret itself.
func credStatus(d SettingsData, key 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 {
@@ -57,7 +63,51 @@ func settingsBody(d SettingsData) 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 class=\"space-y-8 max-w-3xl\"><h1 class=\"text-3xl font-semibold\">Settings</h1><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Apify, eBay and Ntfy</h2><form method=\"post\" action=\"/settings\" class=\"space-y-4\">") if s := d.CredentialStatus[key]; s != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"v-muted text-xs mt-1\">Status: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(s)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 40, Col: 14}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
func settingsBody(d SettingsData) 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_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"space-y-8 max-w-3xl\"><h1 class=\"text-3xl font-semibold\">Settings</h1><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Apify, eBay and Ntfy</h2><form method=\"post\" action=\"/settings\" class=\"space-y-4\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -65,91 +115,58 @@ func settingsBody(d SettingsData) templ.Component {
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, 2, "<div><label class=\"v-label\">Apify API Key</label> <input class=\"v-input font-mono\" type=\"password\" name=\"apify_api_key\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div><label class=\"v-label\">Apify API Key</label> <input class=\"v-input font-mono\" type=\"password\" name=\"apify_api_key\" autocomplete=\"off\" placeholder=\"leave blank to keep current value\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string templ_7745c5c3_Err = credStatus(d, "apify_api_key").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["apify_api_key"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 41, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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=\"border-t border-white/10 pt-4\"><label class=\"v-label\">eBay App ID (Client ID)</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ebay_client_id\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><div class=\"border-t border-white/10 pt-4\"><label class=\"v-label\">eBay App ID (Client ID)</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ebay_client_id\" autocomplete=\"off\" placeholder=\"used for eBay marketplaces instead of Apify\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string templ_7745c5c3_Err = credStatus(d, "ebay_client_id").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ebay_client_id"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 45, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, "\" placeholder=\"used for eBay marketplaces instead of Apify\"></div><div><label class=\"v-label\">eBay Cert ID (Client Secret)</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ebay_client_secret\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div><div><label class=\"v-label\">eBay Cert ID (Client Secret)</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ebay_client_secret\" autocomplete=\"off\" placeholder=\"leave blank to keep current value\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = credStatus(d, "ebay_client_secret").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div><label class=\"v-label\">eBay Daily Call Limit</label> <input class=\"v-input font-mono\" name=\"ebay_daily_call_limit\" type=\"number\" value=\"")
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.ResolveAttributeValue(d.Values["ebay_client_secret"]) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ebay_daily_call_limit"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 49, Col: 118} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 70, Col: 122}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 5, "\"></div><div><label class=\"v-label\">eBay Daily Call Limit</label> <input class=\"v-input font-mono\" name=\"ebay_daily_call_limit\" type=\"number\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" placeholder=\"5000 (blank uses config default)\"></div><div class=\"text-sm\"><span class=\"v-muted\">eBay API calls today:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ebay_daily_call_limit"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 53, Col: 122}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" placeholder=\"5000 (blank uses config default)\"></div><div class=\"text-sm\"><span class=\"v-muted\">eBay API calls today:</span> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.EbayDailyLimit > 0 { if d.EbayDailyLimit > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span class=\"font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 58, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"font-mono\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"font-mono\">")
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_Var5 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d (uncapped)", d.EbayUsedToday)) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 60, Col: 77} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 75, Col: 89}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, 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
} }
@@ -157,127 +174,121 @@ func settingsBody(d SettingsData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} } else {
if d.EbayLimitReached() { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"font-mono\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"v-flash-error inline-block ml-2\">Limit reached. eBay polling halted until the next reset (midnight US Pacific).</span>") if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d (uncapped)", d.EbayUsedToday))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 77, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span> ")
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><div class=\"border-t border-white/10 pt-4\"><label class=\"v-label\">Ntfy Base URL</label> <input class=\"v-input\" name=\"ntfy_base_url\" value=\"") if d.EbayLimitReached() {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"v-flash-error inline-block ml-2\">Limit reached. eBay polling halted until the next reset (midnight US Pacific).</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div class=\"border-t border-white/10 pt-4\"><label class=\"v-label\">Ntfy Base URL</label> <input class=\"v-input\" name=\"ntfy_base_url\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_base_url"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 85, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"></div><div><label class=\"v-label\">Ntfy Default Topic</label> <input class=\"v-input\" name=\"ntfy_default_topic\" 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.ResolveAttributeValue(d.Values["ntfy_base_url"]) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_default_topic"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 68, Col: 82} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 89, Col: 92}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 13, "\"></div><div><label class=\"v-label\">Ntfy Default Topic</label> <input class=\"v-input\" name=\"ntfy_default_topic\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"></div><div><label class=\"v-label\">Ntfy Token</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ntfy_token\" autocomplete=\"off\" placeholder=\"tk_... (leave blank to keep current value)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = credStatus(d, "ntfy_token").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div><div><label class=\"v-label\">Global Poll Interval (minutes)</label> <input class=\"v-input font-mono\" name=\"global_poll_interval_minutes\" type=\"number\" value=\"")
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.ResolveAttributeValue(d.Values["ntfy_default_topic"]) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["global_poll_interval_minutes"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 72, Col: 92} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 98, Col: 136}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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><label class=\"v-label\">Ntfy Token</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ntfy_token\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\"></div><div><label class=\"v-label\">Match Confidence Threshold</label> <input class=\"v-input font-mono\" name=\"match_confidence_threshold\" type=\"number\" min=\"0\" max=\"1\" step=\"0.05\" 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.ResolveAttributeValue(d.Values["ntfy_token"]) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["match_confidence_threshold"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 76, Col: 102} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 102, Col: 160}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, 15, "\" placeholder=\"tk_... (leave blank if ntfy is unauthenticated)\"></div><div><label class=\"v-label\">Global Poll Interval (minutes)</label> <input class=\"v-input font-mono\" name=\"global_poll_interval_minutes\" type=\"number\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["global_poll_interval_minutes"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 80, Col: 136}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"></div><div><label class=\"v-label\">Match Confidence Threshold</label> <input class=\"v-input font-mono\" name=\"match_confidence_threshold\" type=\"number\" min=\"0\" max=\"1\" step=\"0.05\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["match_confidence_threshold"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 84, Col: 160}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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
} }
if !d.IsAdmin { if !d.IsAdmin {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"v-muted text-sm\">Read-only for non-admin users.</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"v-muted text-sm\">Read-only for non-admin users.</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"flex items-center gap-3 pt-1\"><button class=\"v-btn\" type=\"submit\">Save</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ntfy\">Test Ntfy</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-apify\">Test Apify</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ebay\">Test eBay</button></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"flex items-center gap-3 pt-1\"><button class=\"v-btn\" type=\"submit\">Save</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ntfy\">Test Ntfy</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-apify\">Test Apify</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ebay\">Test eBay</button></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, 20, "</form>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.TestNtfyOK != "" { if d.TestNtfyOK != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"v-flash mt-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 98, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if d.TestApifyOK != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"v-flash mt-3\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"v-flash mt-3\">")
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_Var11 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 101, Col: 45} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 116, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, 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
} }
@@ -286,17 +297,17 @@ func settingsBody(d SettingsData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if d.TestEbayOK != "" { if d.TestApifyOK != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"v-flash mt-3\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"v-flash mt-3\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestEbayOK) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 104, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 119, Col: 45}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, 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
} }
@@ -305,40 +316,40 @@ func settingsBody(d SettingsData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</section><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Change Password</h2>") if d.TestEbayOK != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"v-flash mt-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestEbayOK)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 122, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</section><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Change Password</h2>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.PasswordError != "" { if d.PasswordError != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"v-flash-error\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"v-flash-error\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 111, Col: 48} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 129, Col: 48}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if d.PasswordMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"v-flash\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordMsg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 114, Col: 40}
}
_, 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
} }
@@ -347,7 +358,26 @@ func settingsBody(d SettingsData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<form method=\"post\" action=\"/settings/password\" class=\"space-y-4\">") if d.PasswordMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"v-flash\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordMsg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 132, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<form method=\"post\" action=\"/settings/password\" class=\"space-y-4\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -355,45 +385,26 @@ func settingsBody(d SettingsData) templ.Component {
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, "<div><label class=\"v-label\">Current Password</label> <input class=\"v-input\" type=\"password\" name=\"current_password\"></div><div><label class=\"v-label\">New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password\"></div><div><label class=\"v-label\">Confirm New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password_confirm\"></div><button class=\"v-btn\" type=\"submit\">Update Password</button></form></section>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<div><label class=\"v-label\">Current Password</label> <input class=\"v-input\" type=\"password\" name=\"current_password\"></div><div><label class=\"v-label\">New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password\"></div><div><label class=\"v-label\">Confirm New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password_confirm\"></div><button class=\"v-btn\" type=\"submit\">Update Password</button></form></section>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.IsAdmin { if d.IsAdmin {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Users</h2>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Users</h2>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.UserError != "" { if d.UserError != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<div class=\"v-flash-error\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"v-flash-error\">")
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_Var16 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 138, Col: 45} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 156, Col: 45}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if d.UserMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"v-flash\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 141, Col: 37}
}
_, 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
} }
@@ -402,126 +413,145 @@ func settingsBody(d SettingsData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<table class=\"v-table mb-4\"><thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead> <tbody>") if d.UserMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"v-flash\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 159, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<table class=\"v-table mb-4\"><thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, u := range d.Users { for _, u := range d.Users {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<tr><td>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<tr><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 166, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</td><td class=\"v-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 167, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</td><td class=\"v-muted text-sm\">")
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(u.Username) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 148, Col: 24} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 168, Col: 70}
} }
_, 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, 41, "</td><td class=\"v-muted\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</td><td class=\"text-right\"><form class=\"inline\" method=\"post\" action=\"")
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 templ.SafeURL
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role)) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/reset-password", u.ID)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 149, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 170, Col: 113}
} }
_, 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, 42, "</td><td class=\"v-muted text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02")) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 150, Col: 70} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 171, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
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, "</td><td class=\"text-right\"><form class=\"inline\" method=\"post\" action=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\"> <input type=\"password\" class=\"v-input inline-block max-w-[140px]\" name=\"new_password\" placeholder=\"new password\"> <button class=\"v-btn-ghost\" type=\"submit\">Reset</button></form><form class=\"inline\" method=\"post\" action=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var23 templ.SafeURL var templ_7745c5c3_Var23 templ.SafeURL
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/reset-password", u.ID))) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/delete", u.ID)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 152, Col: 113} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 175, Col: 105}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
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, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" onsubmit=\"return confirm('Remove user?')\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken) templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 153, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 176, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
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, "\"> <input type=\"password\" class=\"v-input inline-block max-w-[140px]\" name=\"new_password\" placeholder=\"new password\"> <button class=\"v-btn-ghost\" type=\"submit\">Reset</button></form><form class=\"inline\" method=\"post\" action=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"> <button class=\"v-btn-ghost\" type=\"submit\">Remove</button></form></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 templ.SafeURL
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/delete", u.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 157, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" onsubmit=\"return confirm('Remove user?')\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 158, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\"> <button class=\"v-btn-ghost\" type=\"submit\">Remove</button></form></td></tr>")
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, 48, "</tbody></table><form method=\"post\" action=\"/users\" class=\"grid md:grid-cols-4 gap-3 items-end\"><input type=\"hidden\" name=\"csrf_token\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</tbody></table><form method=\"post\" action=\"/users\" class=\"grid md:grid-cols-4 gap-3 items-end\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var27 string var templ_7745c5c3_Var25 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 167, Col: 63} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 185, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) _, 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, 49, "\"><div><label class=\"v-label\">Username</label> <input class=\"v-input\" name=\"username\"></div><div><label class=\"v-label\">Role</label> <select class=\"v-select\" name=\"role\"><option value=\"user\">user</option> <option value=\"admin\">admin</option></select></div><div><label class=\"v-label\">Initial Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><button class=\"v-btn\" type=\"submit\">Add User</button></form></section>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\"><div><label class=\"v-label\">Username</label> <input class=\"v-input\" name=\"username\"></div><div><label class=\"v-label\">Role</label> <select class=\"v-select\" name=\"role\"><option value=\"user\">user</option> <option value=\"admin\">admin</option></select></div><div><label class=\"v-label\">Initial Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><button class=\"v-btn\" type=\"submit\">Add User</button></form></section>")
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, 52, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -545,9 +575,9 @@ func Settings(d SettingsData) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var28 := templ.GetChildren(ctx) templ_7745c5c3_Var26 := templ.GetChildren(ctx)
if templ_7745c5c3_Var28 == nil { if templ_7745c5c3_Var26 == nil {
templ_7745c5c3_Var28 = templ.NopComponent templ_7745c5c3_Var26 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer)