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
.idea/
.vscode/
# Debug log output from `-debug` runs
veola-debug.log
*.log

View File

@@ -1,18 +1,28 @@
# Veola build.
#
# Requires the `templ` CLI (go install github.com/a-h/templ/cmd/templ@latest).
# The Tailwind standalone CLI is fetched on demand into bin/ (gitignored) — no
# node toolchain required. static/css/tailwind.css is a committed build
# artifact so a plain `go build` deploy still has styles; run `make css`
# (or `make build`) after touching templates or static/css/input.css.
# Tool dependencies the `*_VERSION` variables below are the single source of
# truth for every non-Go thing Veola pulls in at build time. To upgrade one,
# bump its version and run `make vendor` (vendored JS) or just `make css`
# (Tailwind) on a clean `bin/` to refetch.
#
# Go module dependencies live in go.mod and are bumped via `make update-deps`.
TAILWIND_VERSION := v3.4.17
TAILWIND_BIN := bin/tailwindcss
# linux-x64 only; change the asset name for other platforms.
TAILWIND_URL := https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64
TEMPL := $(shell go env GOPATH)/bin/templ
HTMX_VERSION := 2.0.4
CHARTJS_VERSION := 4.4.6
TEMPL_VERSION := v0.3.1020
.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
@@ -41,3 +51,32 @@ test:
clean:
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
- 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)
- 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
- 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
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
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 |
| --- | --- |
@@ -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 run` | `build`, then run against `config.toml` |
| `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.
`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
@@ -70,13 +121,22 @@ Other notable config:
./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:
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`.
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`.
## 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.
## 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
- 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).
- 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.
- `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

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 (
"encoding/json"
"log/slog"
"strconv"
"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`.
// The actor accepts keyword searches and standard filters; it targets
// 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
// this row. Empty for URL-only items or rows from non-search sources.
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
@@ -197,8 +261,10 @@ func Decode(items []json.RawMessage, source string) ([]UnifiedResult, error) {
}
case SourceYahooJP:
for _, raw := range items {
slog.Debug("yahoo raw item", "json", string(raw))
var r YahooAuctionsJPResult
if err := json.Unmarshal(raw, &r); err != nil {
slog.Debug("yahoo decode failed", "err", err, "json", string(raw))
continue
}
img := ""
@@ -213,6 +279,7 @@ func Decode(items []json.RawMessage, source string) ([]UnifiedResult, error) {
Store: "yahoo-auctions-jp (via zenmarket)",
ImageURL: img,
Source: source,
EndsAt: parseYahooEndingDate(r.EndingDate),
})
}
case SourceMercariJP:

View File

@@ -73,6 +73,19 @@ func CheckPassword(hash, plain string) bool {
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.
func (m *Manager) LogIn(ctx context.Context, userID int64) error {
if err := m.Sessions.RenewToken(ctx); err != nil {

View File

@@ -37,6 +37,22 @@ func Open(path string) (*sql.DB, error) {
conn.Close()
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
}

View File

@@ -274,20 +274,20 @@ func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error)
INSERT INTO items (
name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
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,
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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category),
nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority,
it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock),
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),
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.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 = ?,
ntfy_topic = ?, ntfy_priority = ?, 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 = ?, active = ?, updated_at = CURRENT_TIMESTAMP
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,
it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock),
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),
boolToInt(it.UsePriceComparison), boolToInt(it.Active),
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.
func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models.Item, errMsg string) error {
var (
bestPrice sql.NullFloat64
bestStore, bestURL, bestImage, bestTitle, errField sql.NullString
bestPrice sql.NullFloat64
bestCurrency, bestStore, bestURL, bestImage, bestTitle, errField sql.NullString
)
if best != nil {
bestPrice = nullFloat(best.BestPrice)
bestCurrency = nullStr(best.BestPriceCurrency)
bestStore = nullStr(best.BestPriceStore)
bestURL = nullStr(s.enc(best.BestPriceURL))
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, `
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 = ?,
last_polled_at = CURRENT_TIMESTAMP, last_poll_error = ?
WHERE id = ?
`, bestPrice, bestStore, bestURL, bestImage, bestTitle, errField, id)
`, bestPrice, bestCurrency, bestStore, bestURL, bestImage, bestTitle, errField, id)
return err
}
const itemSelect = `
SELECT id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
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,
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
FROM items
`
@@ -532,10 +533,10 @@ func scanItem(r rowScanner) (*models.Item, error) {
var (
it models.Item
searchQuery, urlS, category, listingType sql.NullString
excludeKw sql.NullString
excludeKw, condition, region sql.NullString
actorA, actorS, actorP sql.NullString
ntfyTopic, lastPollErr sql.NullString
bestStore, bestURL, bestImage, bestTitle sql.NullString
bestCurrency, bestStore, bestURL, bestImage, bestTitle sql.NullString
targetPrice, minPrice, bestPrice sql.NullFloat64
includeOOS, usePC, active int
lastPolledAt sql.NullTime
@@ -543,9 +544,9 @@ func scanItem(r rowScanner) (*models.Item, error) {
if err := r.Scan(
&it.ID, &it.Name, &searchQuery, &urlS, &category, &targetPrice, &ntfyTopic, &it.NtfyPriority,
&it.PollIntervalMinutes, &includeOOS, &minPrice, &excludeKw,
&listingType,
&listingType, &condition, &region,
&actorA, &actorS, &actorP, &usePC,
&active, &lastPolledAt, &lastPollErr, &bestPrice, &bestStore,
&active, &lastPolledAt, &lastPollErr, &bestPrice, &bestCurrency, &bestStore,
&bestURL, &bestImage, &bestTitle, &it.CreatedAt, &it.UpdatedAt,
); err != nil {
return nil, err
@@ -556,11 +557,14 @@ func scanItem(r rowScanner) (*models.Item, error) {
it.URL = urlS.String
it.Category = category.String
it.ListingType = listingType.String
it.Condition = condition.String
it.Region = region.String
it.ActorActive = actorA.String
it.ActorSold = actorS.String
it.ActorPriceCompare = actorP.String
it.NtfyTopic = ntfyTopic.String
it.LastPollError = lastPollErr.String
it.BestPriceCurrency = bestCurrency.String
it.BestPriceStore = bestStore.String
it.BestPriceURL = bestURL.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) {
res, err := s.DB.ExecContext(ctx, `
INSERT INTO results (item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
INSERT INTO results (item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at, ends_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
`,
r.ItemID, s.enc(r.Title), nullFloat(r.Price), r.Currency,
nullStr(r.URL), nullStr(r.Source), s.enc(r.ImageURL),
nullStr(s.enc(r.MatchedQuery)),
boolToInt(r.Alerted),
nullTime(r.EndsAt),
)
if err != nil {
return 0, err
@@ -624,11 +629,28 @@ func (s *Store) MarkResultAlerted(ctx context.Context, id int64) error {
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 {
ItemID int64 // 0 = all items
Limit int
Offset int
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) {
@@ -646,14 +668,22 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul
limit = 20
}
args := []any{}
where := ""
var conds []string
if q.ItemID != 0 {
where = `WHERE item_id = ?`
conds = append(conds, `item_id = ?`)
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)
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 ?
`, where, order), args...)
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
price sql.NullFloat64
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
}
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.Price = ptrFloat(price)
r.Alerted = alerted != 0
r.EndsAt = ptrTime(endsAt)
out = append(out, r)
}
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
q := `SELECT COUNT(*) FROM results`
args := []any{}
var conds []string
if itemID != 0 {
q += ` WHERE item_id = ?`
conds = append(conds, `item_id = ?`)
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)
return n, err
}
@@ -710,6 +798,49 @@ func (s *Store) InsertPricePoint(ctx context.Context, p *models.PricePoint) erro
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) {
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`,

View File

@@ -23,6 +23,8 @@ CREATE TABLE IF NOT EXISTS items (
min_price REAL,
exclude_keywords TEXT,
listing_type TEXT,
condition TEXT,
region TEXT,
actor_active TEXT,
actor_sold TEXT,
actor_price_compare TEXT,
@@ -31,6 +33,7 @@ CREATE TABLE IF NOT EXISTS items (
last_polled_at DATETIME,
last_poll_error TEXT,
best_price REAL,
best_price_currency TEXT,
best_price_store TEXT,
best_price_url TEXT,
best_price_image_url TEXT,
@@ -61,7 +64,8 @@ CREATE TABLE IF NOT EXISTS results (
image_url TEXT,
matched_query TEXT,
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);

View File

@@ -149,6 +149,8 @@ type browseItemSummary struct {
Seller struct {
Username string `json:"username"`
} `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.
@@ -174,8 +176,20 @@ func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error)
q := url.Values{}
q.Set("q", query)
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 != "" {
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()
@@ -214,6 +228,12 @@ func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error)
if 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{
Title: s.Title,
Price: price,
@@ -221,6 +241,7 @@ func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error)
URL: s.ItemWebURL,
Store: store,
ImageURL: img,
EndsAt: endsAt,
})
}
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
import "strings"
import (
"strings"
"time"
)
// 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
@@ -14,6 +17,14 @@ type SearchParams struct {
// ListingType is Veola's vocabulary ("all", "bin"/"buy_it_now",
// "auction"); it is mapped to a buyingOptions filter.
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 int
}
@@ -28,6 +39,10 @@ type Listing struct {
URL string
Store 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",
@@ -92,3 +107,34 @@ func buyingOptionsFilter(listingType string) string {
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"))
password := r.PostFormValue("password")
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) {
render(w, r, templates.Login(templates.LoginData{
Page: a.page(r, "Sign in", ""),

View File

@@ -12,7 +12,7 @@ import (
func (a *App) GetDashboard(w http.ResponseWriter, r *http.Request) {
d, err := a.dashboardData(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
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) {
d, err := a.dashboardData(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
// 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 {
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 {
return templates.DashboardData{}, err
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"log/slog"
"net/http"
"os"
"strconv"
"time"
@@ -52,9 +53,19 @@ func (a *App) Routes() http.Handler {
r.Use(middleware.Recoverer)
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))
// 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.
r.Group(func(r chi.Router) {
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) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
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())
// 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{
Page: a.page(r, "Items", "items"),
Items: items,
Categories: cats,
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.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.ActorSold = strings.TrimSpace(r.PostFormValue("actor_sold"))
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,
ListingType: it.ListingType,
ActorIDs: strings.Join(actorIDs, ","),
Condition: it.Condition,
Region: it.Region,
MaxResults: 30,
}
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,
Marketplaces: it.Marketplaces,
ListingType: it.ListingType,
Condition: it.Condition,
Region: it.Region,
ActorActive: it.ActorActive,
ActorSold: it.ActorSold,
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)
if err != nil {
http.Error(w, "could not save item: "+err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
it.ID = id
@@ -361,7 +378,7 @@ func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) {
updated.ID = id
updated.Active = existing.Active
if err := a.Store.UpdateItem(r.Context(), &updated); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
a.Scheduler.SyncItem(updated)
@@ -377,17 +394,18 @@ func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) {
}
it.Active = !it.Active
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
}
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) {
id := intParam(r, "id")
if err := a.Store.DeleteItem(r.Context(), id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
a.Scheduler.RemoveItem(id)
@@ -410,31 +428,22 @@ func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) {
defer cancel()
a.Scheduler.RunPoll(ctx, *it)
// RunPoll writes best price, last_polled_at, and last_poll_error; re-fetch
// so the rendered partial shows the post-poll state.
fresh, err := a.Store.GetItem(r.Context(), id)
if err != nil || fresh == nil {
http.Error(w, "could not reload item after run", http.StatusInternalServerError)
// A partial swap (single row or just the results table) leaves the rest
// of the page — best-price card, price chart, "last polled" time, badge —
// looking stale, so the run reads as a no-op. Tell htmx to do a full
// reload so every derived view picks up the post-poll state.
if r.Header.Get("HX-Request") != "" {
w.Header().Set("HX-Refresh", "true")
w.WriteHeader(http.StatusNoContent)
return
}
// The results page asks for a refreshed listing table; the items list
// asks for a refreshed row. Both POST to this same endpoint.
// Non-htmx fallback: redirect back to the originating page.
target := "/items"
if r.PostFormValue("from") == "results" {
d, err := a.buildItemResultsData(r, fresh, 1, "found_desc")
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
target = fmt.Sprintf("/items/%d/results", id)
}
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) {

View File

@@ -10,8 +10,13 @@ import (
// previewKey caches the *raw* apify result set (post-decode, post-merge,
// 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.
//
// 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 {
Queries, URL, Marketplace, ListingType, ActorIDs string
Condition, Region string
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"))
d, err := a.buildItemResultsData(r, it, page, r.URL.Query().Get("order"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
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
}
total, err := a.Store.CountResults(r.Context(), it.ID)
total, err := a.Store.CountResults(r.Context(), it.ID, true)
if err != nil {
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{
ItemID: it.ID,
Limit: resultsPerPage,
Offset: (page - 1) * resultsPerPage,
Order: order,
ItemID: it.ID,
Limit: resultsPerPage,
Offset: (page - 1) * resultsPerPage,
Order: order,
ExcludeEnded: true,
})
if err != nil {
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
}
// 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{
Page: a.page(r, it.Name, "items"),
Item: *it,
@@ -78,6 +83,7 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o
TotalPages: totalPages,
Order: order,
HistoryChartJSON: buildChartJSON(history),
EndingSoon: endingSoon,
}, nil
}
@@ -101,7 +107,7 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
items, err := a.Store.ListItems(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
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{
ItemID: itemID,
Limit: 200,
ItemID: itemID,
Limit: 200,
ExcludeEnded: true,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
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{
Page: a.page(r, "Results", "results"),
Items: items,
Results: rows,
ItemID: itemID,
From: from,
To: to,
Page: a.page(r, "Results", "results"),
Items: items,
Results: rows,
ItemID: itemID,
From: from,
To: to,
EndingSoon: endingSoon,
}))
}

View File

@@ -26,6 +26,40 @@ var settingsKeys = []string{
"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) {
values, err := a.Store.GetAllSettings(r.Context())
if err != nil {
@@ -38,19 +72,20 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
cur := auth.CurrentUserFromRequest(r)
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
return templates.SettingsData{
Page: a.page(r, "Settings", "settings"),
Values: values,
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
Users: users,
EbayUsedToday: ebayUsed,
EbayDailyLimit: ebayLimit,
Page: a.page(r, "Settings", "settings"),
Values: values,
CredentialStatus: a.credentialStatus(values),
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
Users: users,
EbayUsedToday: ebayUsed,
EbayDailyLimit: ebayLimit,
}, nil
}
func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) {
d, err := a.settingsData(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
render(w, r, templates.Settings(d))
@@ -68,8 +103,14 @@ func (a *App) PostSettings(w http.ResponseWriter, r *http.Request) {
}
for _, k := range settingsKeys {
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 {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
}
@@ -92,7 +133,7 @@ func (a *App) PostPasswordChange(w http.ResponseWriter, r *http.Request) {
d, err := a.settingsData(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
@@ -115,7 +156,7 @@ func (a *App) PostPasswordChange(w http.ResponseWriter, r *http.Request) {
return
}
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
}
d.PasswordMsg = "Password updated"
@@ -130,7 +171,7 @@ func (a *App) PostTestNtfy(w http.ResponseWriter, r *http.Request) {
}
d, err := a.settingsData(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
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)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
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)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
// 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) {
d, err := a.settingsData(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
a.serverError(w, r, err)
return
}
d.UserMsg = msg

View File

@@ -35,6 +35,12 @@ type Item struct {
ExcludeKeywords string
Marketplaces []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
ActorSold string
ActorPriceCompare string
@@ -43,6 +49,7 @@ type Item struct {
LastPolledAt *time.Time
LastPollError string
BestPrice *float64
BestPriceCurrency string
BestPriceStore string
BestPriceURL string
BestPriceImageURL string
@@ -63,6 +70,10 @@ type Result struct {
MatchedQuery string
Alerted bool
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

View File

@@ -220,6 +220,14 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
continue
}
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
}
alerted := false
@@ -242,6 +250,7 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
ImageURL: r.ImageURL,
MatchedQuery: r.MatchedQuery,
Alerted: alerted,
EndsAt: r.EndsAt,
})
if err != nil {
slog.Error("insert result failed", "err", err)
@@ -257,6 +266,7 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
bp := best.Price
_ = s.store.UpdateItemPollResult(ctx, it.ID, &models.Item{
BestPrice: &bp,
BestPriceCurrency: best.Currency,
BestPriceStore: best.Store,
BestPriceURL: best.URL,
BestPriceImageURL: best.ImageURL,
@@ -365,6 +375,7 @@ func (s *Scheduler) ExecutePlan(ctx context.Context, p actorPlan) ([]apify.Unifi
Store: l.Store,
ImageURL: l.ImageURL,
Source: apify.SourceActiveEbay,
EndsAt: l.EndsAt,
})
}
default:
@@ -549,6 +560,8 @@ func (s *Scheduler) buildInputsForQuery(it models.Item, query string, markets []
MarketplaceID: ebay.MarketplaceID(mk),
Query: query,
ListingType: it.ListingType,
Condition: it.Condition,
Region: it.Region,
Limit: 30,
},
})

14
main.go
View File

@@ -27,8 +27,19 @@ import (
func main() {
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()
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 {
slog.Error("fatal", "err", err)
os.Exit(1)
@@ -74,6 +85,9 @@ func run(configPath string) error {
Addr: addr,
Handler: app.Routes(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
errCh := make(chan error, 1)

View File

@@ -26,16 +26,54 @@ a { color: var(--accent); }
a:hover { text-decoration: underline; }
.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-radius: 8px;
border-radius: 10px;
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 {
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-radius: 8px;
border-radius: 10px;
}
.v-divider { border-top: 1px solid var(--border); }
@@ -138,6 +176,19 @@ a:hover { text-decoration: underline; }
}
.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 {
background: #f3ead8;
border-radius: 12px;
@@ -173,7 +224,9 @@ table.v-table td {
border-bottom: 1px solid var(--border);
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-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-request .htmx-indicator,
.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;
}
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',
data: {
labels: data.labels,
@@ -17,19 +39,37 @@
label: 'Best price',
data: data.points,
borderColor: '#00e4a4',
backgroundColor: 'rgba(0,228,164,0.15)',
pointBackgroundColor: '#e84040',
borderWidth: 2.5,
backgroundColor: fill,
pointBackgroundColor: '#ffffff',
pointBorderColor: '#00e4a4',
pointBorderWidth: 1.5,
pointRadius: 3,
tension: 0.25,
pointHoverRadius: 5,
tension: 0.3,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: {
x: { ticks: { color: '#a8c0f0' }, grid: { color: 'rgba(255,255,255,0.07)' } },
y: { 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.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="v-card p-5">
<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>
if d.Stats.UnpricedCount > 0 {
<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 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">{ 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>
</div>
@@ -108,7 +108,7 @@ templ DashboardBody(d DashboardData) {
templ statCard(label, value, sub string) {
<div class="v-card p-4">
<div class="v-muted text-xs uppercase tracking-wide">{ label }</div>
<div class="font-mono text-3xl mt-1">{ value }</div>
<div class="font-mono text-3xl mt-1" data-countup>{ value }</div>
if sub != "" {
<div class="v-muted text-xs mt-1">{ sub }</div>
}

View File

@@ -88,14 +88,14 @@ func DashboardBody(d DashboardData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div><div class=\"grid md:grid-cols-2 gap-4 mb-6\"><div class=\"v-card p-5\"><div class=\"v-muted text-sm uppercase tracking-wide\">Potential Spend</div><div class=\"font-mono text-4xl mt-2\">")
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.PotentialSpend))
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))
if templ_7745c5c3_Err != nil {
@@ -137,14 +137,14 @@ func DashboardBody(d DashboardData) templ.Component {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.MoneySaved))
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))
if templ_7745c5c3_Err != nil {
@@ -346,14 +346,14 @@ func statCard(label, value, sub string) templ.Component {
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(value)
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))
if templ_7745c5c3_Err != nil {

View File

@@ -16,6 +16,43 @@ type ItemFormData struct {
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 {
Value 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>
</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">
<input type="checkbox" name="include_out_of_stock" checked?={ d.Item.IncludeOutOfStock } value="1"/>
<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 }
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 {
Value string
Label string
@@ -138,7 +175,7 @@ func itemFormBody(d ItemFormData) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Name)
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))
if templ_7745c5c3_Err != nil {
@@ -167,7 +204,7 @@ func itemFormBody(d ItemFormData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(e)
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))
if templ_7745c5c3_Err != nil {
@@ -223,7 +260,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var5 templ.SafeURL
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(formAction(d))
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))
if templ_7745c5c3_Err != nil {
@@ -254,7 +291,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.Name)
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)
if templ_7745c5c3_Err != nil {
@@ -282,7 +319,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(c)
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)
if templ_7745c5c3_Err != nil {
@@ -305,7 +342,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(c)
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))
if templ_7745c5c3_Err != nil {
@@ -323,7 +360,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(newCategory(d.Item.Category, d.Categories))
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)
if templ_7745c5c3_Err != nil {
@@ -336,7 +373,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.SearchQuery)
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))
if templ_7745c5c3_Err != nil {
@@ -349,7 +386,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.URL)
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)
if templ_7745c5c3_Err != nil {
@@ -362,7 +399,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(optFloat(d.Item.TargetPrice))
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)
if templ_7745c5c3_Err != nil {
@@ -375,7 +412,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.NtfyTopic)
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)
if templ_7745c5c3_Err != nil {
@@ -393,7 +430,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(p)
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)
if templ_7745c5c3_Err != nil {
@@ -416,7 +453,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p)
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))
if templ_7745c5c3_Err != nil {
@@ -439,7 +476,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", opt.Minutes))
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)
if templ_7745c5c3_Err != nil {
@@ -462,7 +499,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
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))
if templ_7745c5c3_Err != nil {
@@ -485,7 +522,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
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)
if templ_7745c5c3_Err != nil {
@@ -508,7 +545,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
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))
if templ_7745c5c3_Err != nil {
@@ -526,7 +563,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(customMarketplacesCSV(d.Item))
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)
if templ_7745c5c3_Err != nil {
@@ -554,7 +591,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(lt)
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)
if templ_7745c5c3_Err != nil {
@@ -577,7 +614,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(lt)
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))
if templ_7745c5c3_Err != nil {
@@ -595,7 +632,7 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(optFloat(d.Item.MinPrice))
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)
if templ_7745c5c3_Err != nil {
@@ -608,92 +645,184 @@ func itemFormInner(d ItemFormData) templ.Component {
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.ExcludeKeywords)
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))
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
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 {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorActive)
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorActive)
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 {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorSold)
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorSold)
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 {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorPriceCompare)
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorPriceCompare)
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 {
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 {
return templ_7745c5c3_Err
}
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 {
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 {
return templ_7745c5c3_Err
}
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 {
return templ_7745c5c3_Err
}
} 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 {
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 {
return templ_7745c5c3_Err
}
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 {
return templ_7745c5c3_Err
}
@@ -744,9 +873,9 @@ func ItemForm(d ItemFormData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var28 := templ.GetChildren(ctx)
if templ_7745c5c3_Var28 == nil {
templ_7745c5c3_Var28 = templ.NopComponent
templ_7745c5c3_Var32 := templ.GetChildren(ctx)
if templ_7745c5c3_Var32 == nil {
templ_7745c5c3_Var32 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Layout(d.Page, itemFormBody(d)).Render(ctx, templ_7745c5c3_Buffer)

View File

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

View File

@@ -43,6 +43,8 @@ type FormValues struct {
IncludeOutOfStock bool
Marketplaces []string
ListingType string
Condition string
Region string
ActorActive string
ActorSold string
ActorPriceCompare string
@@ -78,7 +80,7 @@ func ItemPreview(d PreviewData) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error)
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))
if templ_7745c5c3_Err != nil {
@@ -101,7 +103,7 @@ func ItemPreview(d PreviewData) templ.Component {
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))
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))
if templ_7745c5c3_Err != nil {
@@ -144,7 +146,7 @@ func ItemPreview(d PreviewData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ImageURL)
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)
if templ_7745c5c3_Err != nil {
@@ -162,7 +164,7 @@ func ItemPreview(d PreviewData) templ.Component {
var templ_7745c5c3_Var5 templ.SafeURL
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL))
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))
if templ_7745c5c3_Err != nil {
@@ -175,7 +177,7 @@ func ItemPreview(d PreviewData) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
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))
if templ_7745c5c3_Err != nil {
@@ -188,7 +190,7 @@ func ItemPreview(d PreviewData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(r.Store)
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))
if templ_7745c5c3_Err != nil {
@@ -201,7 +203,7 @@ func ItemPreview(d PreviewData) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(r.Price, r.Currency))
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))
if templ_7745c5c3_Err != nil {
@@ -225,7 +227,7 @@ func ItemPreview(d PreviewData) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("and %d more", len(d.Results)-6))
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))
if templ_7745c5c3_Err != nil {
@@ -248,7 +250,7 @@ func ItemPreview(d PreviewData) templ.Component {
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))
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))
if templ_7745c5c3_Err != nil {
@@ -301,7 +303,7 @@ func previewBest(d PreviewData) templ.Component {
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Results[d.BestIndex].ImageURL)
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)
if templ_7745c5c3_Err != nil {
@@ -324,7 +326,7 @@ func previewBest(d PreviewData) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(d.Results[d.BestIndex].Price, d.Currency))
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))
if templ_7745c5c3_Err != nil {
@@ -337,7 +339,7 @@ func previewBest(d PreviewData) templ.Component {
var templ_7745c5c3_Var14 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(d.Results[d.BestIndex].URL))
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))
if templ_7745c5c3_Err != nil {
@@ -350,7 +352,7 @@ func previewBest(d PreviewData) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].Title)
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))
if templ_7745c5c3_Err != nil {
@@ -363,7 +365,7 @@ func previewBest(d PreviewData) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].Store)
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))
if templ_7745c5c3_Err != nil {
@@ -381,7 +383,7 @@ func previewBest(d PreviewData) templ.Component {
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].MatchedQuery)
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))
if templ_7745c5c3_Err != nil {
@@ -429,7 +431,7 @@ func confirmForm(d PreviewData) templ.Component {
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
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)
if templ_7745c5c3_Err != nil {
@@ -493,6 +495,14 @@ func confirmForm(d PreviewData) templ.Component {
if templ_7745c5c3_Err != nil {
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)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
@@ -555,7 +565,7 @@ func hidden(name, value string) templ.Component {
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
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)
if templ_7745c5c3_Err != nil {
@@ -568,7 +578,7 @@ func hidden(name, value string) templ.Component {
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(value)
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)
if templ_7745c5c3_Err != nil {
@@ -611,7 +621,7 @@ func hiddenBool(name string, value bool) templ.Component {
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
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)
if templ_7745c5c3_Err != nil {

View File

@@ -2,6 +2,8 @@ package templates
import (
"fmt"
"math"
"strings"
"veola/internal/models"
)
@@ -11,6 +13,85 @@ type ItemsData struct {
Items []models.Item
Categories []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) {
@@ -41,6 +122,7 @@ templ itemsBody(d ItemsData) {
<th>Category</th>
<th>Target</th>
<th>Best Price</th>
<th>Trend</th>
<th>Last Polled</th>
<th>Status</th>
<th></th>
@@ -48,7 +130,7 @@ templ itemsBody(d ItemsData) {
</thead>
<tbody id="items-tbody">
for _, it := range d.Items {
@itemRow(it, d.CSRFToken)
@itemRow(it, d.CSRFToken, d.PriceHistory[it.ID])
}
</tbody>
</table>
@@ -60,7 +142,7 @@ templ itemsBody(d ItemsData) {
templ itemsEmpty() {
<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"/>
<img src="/static/img/veola.avif" alt="Veola"/>
</div>
<div>
<h2 class="text-xl font-semibold mb-2">Nothing on the watchlist.</h2>
@@ -70,10 +152,15 @@ templ itemsEmpty() {
</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) }>
<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 != "" {
<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>
@@ -89,7 +176,12 @@ templ itemRow(it models.Item, csrf string) {
</td>
<td>
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 != "" {
<a class="text-xs" href={ templ.SafeURL(it.BestPriceURL) } target="_blank" rel="noopener">{ it.BestPriceStore }</a>
} else if it.BestPriceStore != "" {
@@ -99,6 +191,15 @@ templ itemRow(it models.Item, csrf string) {
<span class="v-muted">not yet</span>
}
</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">
if it.LastPolledAt != nil {
<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))
}
// ItemRow renders a single row partial, used by HTMX endpoints.
templ ItemRow(it models.Item, csrf string) {
@itemRow(it, csrf)
// ItemRow renders a single row partial, used by HTMX endpoints. Callers
// that don't have history cheaply on hand pass nil; the sparkline cell
// 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.

View File

@@ -10,6 +10,8 @@ import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"math"
"strings"
"veola/internal/models"
)
@@ -19,6 +21,85 @@ type ItemsData struct {
Items []models.Item
Categories []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 {
@@ -59,7 +140,7 @@ func itemsBody(d ItemsData) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(c)
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)
if templ_7745c5c3_Err != nil {
@@ -82,7 +163,7 @@ func itemsBody(d ItemsData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(c)
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))
if templ_7745c5c3_Err != nil {
@@ -104,12 +185,12 @@ func itemsBody(d ItemsData) templ.Component {
return templ_7745c5c3_Err
}
} 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 {
return templ_7745c5c3_Err
}
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 {
return templ_7745c5c3_Err
}
@@ -148,7 +229,7 @@ func itemsEmpty() templ.Component {
templ_7745c5c3_Var4 = templ.NopComponent
}
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 {
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) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
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
templ_7745c5c3_Var6, 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: 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)
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 templ.SafeURL
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 76, Col: 67}
if isDeal(it) {
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 {
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 {
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 {
return templ_7745c5c3_Err
}
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}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 162, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
if it.LastPollError != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<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=\"")
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_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 {
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)
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
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 {
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)
if templ_7745c5c3_Err != nil {
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 {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(it.Category)
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(it.Category)
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 {
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 {
return templ_7745c5c3_Err
}
if it.TargetPrice != nil {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.TargetPrice, "USD"))
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.TargetPrice, "USD"))
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 {
return templ_7745c5c3_Err
}
} 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 {
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 {
return templ_7745c5c3_Err
}
if it.BestPrice != nil {
var templ_7745c5c3_Var14 = []any{"font-mono text-lg", priceClass(it.BestPrice, it.TargetPrice)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"flex items-center gap-1.5\">")
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
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, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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 {
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 {
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 {
return templ_7745c5c3_Err
}
if it.BestPriceURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<a class=\"text-xs\" href=\"")
var templ_7745c5c3_Var17 string
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 templ.SafeURL
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\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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 {
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 {
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 {
return templ_7745c5c3_Err
}
}
} 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 {
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 {
return templ_7745c5c3_Err
}
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(it.LastPolledAt.Format("2006-01-02 15:04:05"))
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(it.LastPolledAt.Format("2006-01-02 15:04:05"))
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 {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(*it.LastPolledAt))
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(*it.LastPolledAt))
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 {
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 {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "—")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "—")
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
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 {
return templ_7745c5c3_Err
}
} 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 {
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=\"")
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=\"")
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_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 {
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)
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, 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: 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)
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
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)
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
@@ -629,9 +824,9 @@ func Items(d ItemsData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var32 := templ.GetChildren(ctx)
if templ_7745c5c3_Var32 == nil {
templ_7745c5c3_Var32 = templ.NopComponent
templ_7745c5c3_Var39 := templ.GetChildren(ctx)
if templ_7745c5c3_Var39 == nil {
templ_7745c5c3_Var39 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
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.
func ItemRow(it models.Item, csrf string) templ.Component {
// ItemRow renders a single row partial, used by HTMX endpoints. Callers
// 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) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
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)
templ_7745c5c3_Var33 := templ.GetChildren(ctx)
if templ_7745c5c3_Var33 == nil {
templ_7745c5c3_Var33 = templ.NopComponent
templ_7745c5c3_Var40 := templ.GetChildren(ctx)
if templ_7745c5c3_Var40 == nil {
templ_7745c5c3_Var40 = templ.NopComponent
}
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 {
return templ_7745c5c3_Err
}
@@ -689,12 +886,12 @@ func EmptyRow() templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var34 := templ.GetChildren(ctx)
if templ_7745c5c3_Var34 == nil {
templ_7745c5c3_Var34 = templ.NopComponent
templ_7745c5c3_Var41 := templ.GetChildren(ctx)
if templ_7745c5c3_Var41 == nil {
templ_7745c5c3_Var41 = templ.NopComponent
}
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 {
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 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
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>
}
templ Sidebar(active string) {
<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-xl font-semibold tracking-wide">Veola</span>
</div>
</a>
<a href="/" class={ navClass("dashboard", active) }>Dashboard</a>
<a href="/items" class={ navClass("items", active) }>Items</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 {
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 {
return templ_7745c5c3_Err
}
@@ -84,7 +84,7 @@ func Sidebar(active string) templ.Component {
templ_7745c5c3_Var3 = templ.NopComponent
}
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 {
return templ_7745c5c3_Err
}
@@ -236,7 +236,7 @@ func Layout(p Page, body templ.Component) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(p.Flash)
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))
if templ_7745c5c3_Err != nil {
@@ -255,7 +255,7 @@ func Layout(p Page, body templ.Component) templ.Component {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.FlashError)
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))
if templ_7745c5c3_Err != nil {
@@ -353,7 +353,7 @@ func CSRFInput(token string) templ.Component {
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(token)
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)
if templ_7745c5c3_Err != nil {

View File

@@ -7,35 +7,40 @@ type LoginData struct {
}
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="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-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>
if d.Error != "" {
<div class="v-flash-error">{ d.Error }</div>
}
<form method="post" action="/login" class="space-y-4">
@CSRFInput(d.CSRFToken)
<div>
<label class="v-label">Username</label>
<input class="v-input" name="username" autocomplete="username" autofocus value={ d.Username }/>
</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 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 d.Error != "" {
<div class="v-flash-error">{ d.Error }</div>
}
<form method="post" action="/login" class="space-y-4">
@CSRFInput(d.CSRFToken)
<div>
<label class="v-label">Username</label>
<input class="v-input" name="username" autocomplete="username" autofocus value={ d.Username }/>
</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 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 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>
@@ -52,39 +57,44 @@ type SetupData struct {
}
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="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-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>
if d.Error != "" {
<div class="v-flash-error">{ d.Error }</div>
}
<form method="post" action="/setup" class="space-y-4">
@CSRFInput(d.CSRFToken)
<div>
<label class="v-label">Username</label>
<input class="v-input" name="username" autofocus value={ d.Username }/>
</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 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 d.Error != "" {
<div class="v-flash-error">{ d.Error }</div>
}
<form method="post" action="/setup" class="space-y-4">
@CSRFInput(d.CSRFToken)
<div>
<label class="v-label">Username</label>
<input class="v-input" name="username" autofocus value={ d.Username }/>
</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 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 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>

View File

@@ -35,7 +35,7 @@ func loginBody(d LoginData) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
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 {
return templ_7745c5c3_Err
}
@@ -47,7 +47,7 @@ func loginBody(d LoginData) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error)
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))
if templ_7745c5c3_Err != nil {
@@ -73,13 +73,13 @@ func loginBody(d LoginData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Username)
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)
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
@@ -143,7 +143,7 @@ func setupBody(d SetupData) templ.Component {
templ_7745c5c3_Var5 = templ.NopComponent
}
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 {
return templ_7745c5c3_Err
}
@@ -155,7 +155,7 @@ func setupBody(d SetupData) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error)
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))
if templ_7745c5c3_Err != nil {
@@ -181,13 +181,13 @@ func setupBody(d SetupData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Username)
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)
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}

View File

@@ -3,7 +3,9 @@ package templates
import (
"encoding/json"
"fmt"
"time"
"veola/internal/db"
"veola/internal/models"
)
@@ -21,6 +23,9 @@ type ItemResultsData struct {
// normal page load; PostRunItem sets exactly one.
RunMsg 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 {
@@ -35,6 +40,8 @@ type GlobalResultsData struct {
ItemID int64
From string
To string
// EndingSoon mirrors the per-item field but spans every watched item.
EndingSoon *db.EndingSoon
}
type ItemResultRow struct {
@@ -42,8 +49,40 @@ type ItemResultRow struct {
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) {
<div>
@endingSoonStrip(d.EndingSoon)
<div class="flex items-start justify-between mb-4">
<div>
<h1 class="text-3xl font-semibold">{ d.Item.Name }</h1>
@@ -53,7 +92,7 @@ templ itemResultsBody(d ItemResultsData) {
</div>
<div class="text-right">
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 != "" {
<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:
// templ interpolates (and escapes) attribute values but treats
// <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/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>
</th>
<th>Store</th>
<th>Ends</th>
<th>
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "found"))) }>Found</a>
</th>
@@ -146,6 +188,7 @@ templ ItemResultsTable(d ItemResultsData) {
</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>@endsInCell(r.EndsAt)</td>
<td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td>
<td>
if r.Alerted {
@@ -197,6 +240,7 @@ templ ItemResults(d ItemResultsData) {
templ globalResultsBody(d GlobalResultsData) {
<div>
<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">
<div>
<label class="v-label">Item</label>
@@ -220,7 +264,7 @@ templ globalResultsBody(d GlobalResultsData) {
<div class="v-card p-0 overflow-hidden">
<table class="v-table">
<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>
<tbody>
for _, r := range d.Results {
@@ -238,6 +282,7 @@ templ globalResultsBody(d GlobalResultsData) {
</td>
<td class="font-mono">{ fmtPrice(r.Price, r.Currency) }</td>
<td class="v-muted">{ r.Source }</td>
<td>@endsInCell(r.EndsAt)</td>
<td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td>
<td>
if r.Alerted {

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,12 @@ import (
type SettingsData struct {
Page
Values map[string]string
IsAdmin bool
Values map[string]string
// 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
TestNtfyOK string
TestApifyOK string
@@ -28,6 +32,16 @@ func (d SettingsData) EbayLimitReached() bool {
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) {
<div class="space-y-8 max-w-3xl">
<h1 class="text-3xl font-semibold">Settings</h1>
@@ -38,15 +52,18 @@ templ settingsBody(d SettingsData) {
@CSRFInput(d.CSRFToken)
<div>
<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 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={ 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>
<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>
<label class="v-label">eBay Daily Call Limit</label>
@@ -73,7 +90,8 @@ templ settingsBody(d SettingsData) {
</div>
<div>
<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>
<label class="v-label">Global Poll Interval (minutes)</label>

View File

@@ -16,18 +16,22 @@ import (
type SettingsData struct {
Page
Values map[string]string
IsAdmin bool
Users []models.User
TestNtfyOK string
TestApifyOK string
TestEbayOK string
EbayUsedToday int
EbayDailyLimit int
PasswordMsg string
PasswordError string
UserMsg string
UserError string
Values map[string]string
// 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
TestNtfyOK string
TestApifyOK string
TestEbayOK string
EbayUsedToday int
EbayDailyLimit int
PasswordMsg string
PasswordError string
UserMsg string
UserError string
}
// 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
}
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) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
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
}
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 {
return templ_7745c5c3_Err
}
@@ -65,91 +115,58 @@ func settingsBody(d SettingsData) templ.Component {
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
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)
templ_7745c5c3_Err = credStatus(d, "apify_api_key").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
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)
templ_7745c5c3_Err = credStatus(d, "ebay_client_id").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
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 {
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)
if templ_7745c5c3_Err != nil {
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=\"")
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> ")
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
}
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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d (uncapped)", d.EbayUsedToday))
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, 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: 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 {
return templ_7745c5c3_Err
}
@@ -157,127 +174,121 @@ func settingsBody(d SettingsData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if d.EbayLimitReached() {
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>")
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<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 (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 {
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 {
return templ_7745c5c3_Err
}
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 {
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)
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
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 {
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)
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
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 {
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)
if templ_7745c5c3_Err != nil {
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=\"")
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>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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 {
return templ_7745c5c3_Err
}
} 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 {
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 {
return templ_7745c5c3_Err
}
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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK)
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK)
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 {
return templ_7745c5c3_Err
}
@@ -286,17 +297,17 @@ func settingsBody(d SettingsData) templ.Component {
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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestEbayOK)
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK)
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 {
return templ_7745c5c3_Err
}
@@ -305,40 +316,40 @@ func settingsBody(d SettingsData) templ.Component {
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 {
return templ_7745c5c3_Err
}
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError)
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError)
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))
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))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -347,7 +358,26 @@ func settingsBody(d SettingsData) templ.Component {
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 {
return templ_7745c5c3_Err
}
@@ -355,45 +385,26 @@ func settingsBody(d SettingsData) templ.Component {
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
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 {
return templ_7745c5c3_Err
}
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError)
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError)
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))
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))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -402,126 +413,145 @@ func settingsBody(d SettingsData) templ.Component {
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 {
return templ_7745c5c3_Err
}
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 {
return templ_7745c5c3_Err
}
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 {
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))
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role))
var templ_7745c5c3_Var21 templ.SafeURL
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/reset-password", u.ID)))
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))
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
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 {
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 {
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 {
return templ_7745c5c3_Err
}
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 {
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))
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
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)
if templ_7745c5c3_Err != nil {
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=\"")
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>")
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
}
}
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
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 {
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 {
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 {
return templ_7745c5c3_Err
}
@@ -545,9 +575,9 @@ func Settings(d SettingsData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var28 := templ.GetChildren(ctx)
if templ_7745c5c3_Var28 == nil {
templ_7745c5c3_Var28 = templ.NopComponent
templ_7745c5c3_Var26 := templ.GetChildren(ctx)
if templ_7745c5c3_Var26 == nil {
templ_7745c5c3_Var26 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer)