Compare commits

..

4 Commits

Author SHA1 Message Date
prosolis
d87536c879 Fix bugs found in local testing
- Dashboard auto-refresh rendered the full layout into its own
  refresh container, producing a duplicate sidebar every 60s; it now
  renders only the body partial.
- 'Run Now' runs synchronously with a bounded timeout and returns
  refreshed results plus success/error feedback, instead of
  firing-and-forgetting with no signal.
- Price-history chart data moved from a <script> block to a data-
  attribute: templ does not interpolate expressions inside <script>
  element content, so the JSON was emitted literally.
- The htmx indicator spinner was permanently visible due to CSS
  source order; the indicator rules now follow .v-spinner.

Also refreshes README for this session's changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:11:07 -07:00
prosolis
08ff1695e0 Vendor Tailwind via the standalone CLI; drop the Play CDN
Tailwind is now compiled from static/css/input.css into a committed static/css/tailwind.css by the standalone CLI, fetched on demand into bin/ (gitignored) so no Node toolchain is required. layout.templ loads the local stylesheet instead of cdn.tailwindcss.com. Adds a Makefile with generate/css/build/run/test/clean targets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:10:57 -07:00
prosolis
fd1682e11b Harden for public deployment behind a reverse proxy
The session cookie now sets the Secure attribute (server.secure_cookies, default true). Adds chi RealIP and Recoverer middleware plus a securityHeaders middleware that emits a Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy on every response. HSTS is intentionally left to the TLS-terminating proxy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:10:50 -07:00
prosolis
1ae2c50b9a Add eBay Browse API integration with daily call quota
eBay marketplaces are now polled through eBay's official Buy > Browse API (client-credentials OAuth2) instead of an Apify scraper actor; Apify still handles Yahoo JP and Mercari. Browse API calls are tracked per day in a new ebay_api_usage table and capped (default 5000, configurable) on eBay's Pacific-time reset clock, so polling halts before the limit is hit. Credentials live in config.toml [ebay] and are overridable via /settings, which also surfaces the day's running call count.

Also carries the server.secure_cookies config plumbing (field, accessor, example) consumed by the following commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:10:39 -07:00
35 changed files with 1802 additions and 640 deletions

3
.gitignore vendored
View File

@@ -2,6 +2,9 @@
veola-bin
*.exe
# Fetched-on-demand tooling (Tailwind standalone CLI)
/bin/
# Local config (use config.toml.example as template)
config.toml

43
Makefile Normal file
View File

@@ -0,0 +1,43 @@
# 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.
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
.PHONY: all generate css build run test clean
all: build
$(TAILWIND_BIN):
mkdir -p bin
curl -sL --fail $(TAILWIND_URL) -o $(TAILWIND_BIN)
chmod +x $(TAILWIND_BIN)
# Compile Tailwind utilities (scanned from the .templ sources) into
# static/css/tailwind.css. The hand-written component layer is app.css.
css: $(TAILWIND_BIN)
$(TAILWIND_BIN) -c tailwind.config.js -i static/css/input.css -o static/css/tailwind.css --minify
# Regenerate templ Go from the .templ sources.
generate:
$(TEMPL) generate
build: generate css
go build -o veola-bin .
run: build
./veola-bin -config config.toml
test:
go test ./...
clean:
rm -f veola-bin

View File

@@ -1,13 +1,14 @@
# Veola
Self-hosted Go web app that tracks items across e-commerce platforms (eBay, Amazon family, Yahoo Auctions JP, Mercari JP) via the [Apify](https://apify.com) scraping API and pushes deal alerts to a self-hosted [ntfy](https://ntfy.sh) instance.
Self-hosted Go web app that tracks items across e-commerce platforms and pushes deal alerts to a self-hosted [ntfy](https://ntfy.sh) instance. eBay marketplaces are polled through eBay's official [Browse API](https://developer.ebay.com/api-docs/buy/browse/overview.html); Amazon family, Yahoo Auctions JP, and Mercari JP go through the [Apify](https://apify.com) scraping API.
Track. Watch. Notice.
## Features
- Watch arbitrary items across multiple marketplaces with per-item search queries, target prices, and poll intervals
- Active-listing, sold-listing, and price-comparison actors per item
- eBay via the official eBay Browse API, with a per-day call quota tracked and enforced — polling halts before the limit and resets on eBay's Pacific-time clock
- Apify active-listing, sold-listing, and price-comparison actors for the non-eBay marketplaces
- Price-history chart and best-price badge once enough history accumulates
- Deal alerts pushed to ntfy when current price falls at or below target
- Single-binary deploy, SQLite storage, no CGO
@@ -17,22 +18,30 @@ See [`veola-spec.md`](veola-spec.md) for the full specification.
## Requirements
- Go 1.22+ (developed against 1.25)
- An [Apify](https://apify.com) account + API key
- 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
```sh
go build -o veola-bin .
make build
```
This runs `templ generate`, compiles Tailwind, and produces `veola-bin`. Makefile targets:
| Target | What it does |
| --- | --- |
| `make generate` | Regenerate templ Go from `.templ` sources |
| `make css` | Compile `static/css/tailwind.css` from `static/css/input.css` (fetches the Tailwind standalone CLI into `bin/` on first run) |
| `make build` | `generate` + `css` + `go build -o veola-bin .` |
| `make run` | `build`, then run against `config.toml` |
| `make test` | `go test ./...` |
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.
If you change any `.templ` files, regenerate first:
```sh
~/go/bin/templ generate
```
`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.
## Configure
@@ -48,7 +57,12 @@ Both `session_secret` and `encryption_key` must be at least 32 bytes and differe
openssl rand -hex 32
```
`encryption_key` encrypts secrets at rest in SQLite (Apify keys, ntfy settings). Rotating it invalidates stored secrets — re-enter them through `/settings` after rotation.
`encryption_key` encrypts secrets at rest in SQLite (Apify key, eBay credentials, ntfy settings). Rotating it invalidates stored secrets — re-enter them through `/settings` after rotation.
Other notable config:
- `[ebay]``client_id` (App ID), `client_secret` (Cert ID), `environment` (`production` or `sandbox`), and `daily_call_limit` (Browse API calls per day; default 5000). All of `client_id` / `client_secret` / the limit can also be set or overridden at runtime via `/settings`.
- `server.secure_cookies` — sets the `Secure` attribute on the session cookie. Defaults to `true`; keep it on for any HTTPS-reachable deployment, **including behind a TLS-terminating proxy**. Set `false` only for local plain-HTTP testing on a non-localhost address.
## Run
@@ -61,7 +75,7 @@ First-run flow:
1. Visit `http://localhost:8080/`. With no users, you are redirected to `/setup`.
2. Create the admin account.
3. Log in at `/login`.
4. Add items at `/items/new`. Optionally fill in your Apify key and ntfy URL via `/settings` if you didn't put them in `config.toml`.
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.
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`.
@@ -75,12 +89,15 @@ internal/
db/ SQLite schema, migrations, store
models/ domain types
apify/ Apify API client
ebay/ eBay Browse API client (OAuth2 + item search)
ntfy/ ntfy push client
auth/ session + CSRF
scheduler/ poll loop, alert/dedup/badge logic
handlers/ HTTP handlers
templates/ templ components
static/ CSS, vendored htmx
static/ compiled Tailwind + app.css, vendored htmx/Chart.js, JS
tailwind.config.js Tailwind content globs
Makefile build targets
```
## Test
@@ -89,14 +106,23 @@ static/ CSS, vendored htmx
go test ./...
```
Unit tests cover crypto round-trip, db round-trip and dedup, and scheduler alert/badge logic. No handler-level tests yet.
Unit tests cover crypto round-trip, db round-trip and dedup, scheduler alert/badge logic, and eBay marketplace/filter mapping. No handler-level tests yet.
## 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.
- `config.toml` and `veola.db` (plus its `-wal`/`-shm`) hold secrets and live session tokens — keep them `chmod 600` and owned by the service user.
- The process responds to `SIGINT` / `SIGTERM` with a graceful HTTP shutdown (30s timeout) followed by scheduler stop.
- Logs go to stdout as structured `log/slog` records.
### Deploying publicly
Veola speaks plain HTTP and is meant to sit behind a TLS-terminating reverse proxy (e.g. Traefik, Caddy, nginx).
- 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.
## Aesthetic
Sega Genesis blue. Not dark mode, not light mode — blue mode. See the visual design section of `veola-spec.md` for the palette.

View File

@@ -1,6 +1,11 @@
[server]
port = 8080
db_path = "./veola.db"
# Sets the Secure attribute on the session cookie. Leave true for any
# HTTPS-reachable deployment, including behind a TLS-terminating proxy such as
# Traefik. Defaults to true if omitted; set false only for local plain-HTTP
# development on a non-localhost address.
secure_cookies = true
[security]
# Both must be at least 32 bytes and different from each other.
@@ -31,6 +36,21 @@ yahoo_auctions_jp = "meron1122/zenmarket-scraper"
yahoo_auctions_jp_sold = "" # no known verified sold-listings actor for Yahoo JP
mercari_jp = "cloud9_ai/mercari-scraper"
# eBay's official Buy > Browse API. When client_id and client_secret are set,
# eBay marketplaces (ebay.com, ebay.co.uk, ...) are polled through this API
# instead of an Apify scraper actor; Apify still handles Yahoo JP and Mercari.
# client_id is the App ID and client_secret is the Cert ID from your eBay
# developer keyset. Both can also be set/overridden at runtime via /settings.
# environment is "production" (default) or "sandbox".
# daily_call_limit caps Browse API calls per day on eBay's own quota clock
# (midnight US Pacific); once hit, eBay polling halts until the next reset.
# 5000 is the standard Browse API allowance; set a negative value to disable.
[ebay]
client_id = ""
client_secret = ""
environment = "production"
daily_call_limit = 5000
[ntfy]
base_url = "https://ntfy.yourdomain.com"
default_topic = "veola"

19
go.mod
View File

@@ -3,21 +3,24 @@ module veola
go 1.25.0
require (
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/a-h/templ v0.3.1020 // indirect
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de // indirect
github.com/alexedwards/scs/v2 v2.9.0 // indirect
github.com/BurntSushi/toml v1.6.0
github.com/a-h/templ v0.3.1020
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de
github.com/alexedwards/scs/v2 v2.9.0
github.com/go-chi/chi/v5 v5.2.5
github.com/robfig/cron/v3 v3.0.1
golang.org/x/crypto v0.51.0
modernc.org/sqlite v1.50.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sys v0.44.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.50.0 // indirect
)

33
go.sum
View File

@@ -10,10 +10,17 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
@@ -23,14 +30,40 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -38,7 +38,7 @@ type Manager struct {
hmacKey []byte
}
func NewManager(sqlDB *sql.DB, store *db.Store, sessionSecret string) (*Manager, error) {
func NewManager(sqlDB *sql.DB, store *db.Store, sessionSecret string, secureCookies bool) (*Manager, error) {
if len(sessionSecret) < 32 {
return nil, errors.New("session secret too short")
}
@@ -51,7 +51,10 @@ func NewManager(sqlDB *sql.DB, store *db.Store, sessionSecret string) (*Manager,
sm.Cookie.Path = "/"
sm.Cookie.SameSite = http.SameSiteLaxMode
sm.Cookie.Persist = true
// Cookie.Secure left false for self-hosted HTTP deployments; flip via env in deploy.
// Secure must be set whenever the browser-facing connection is HTTPS,
// which includes running behind a TLS-terminating proxy. Resolved from
// config; defaults to true there.
sm.Cookie.Secure = secureCookies
mac := sha256.New()
mac.Write([]byte(sessionSecret))

View File

@@ -13,13 +13,43 @@ type Config struct {
Server ServerConfig `toml:"server"`
Security SecurityConfig `toml:"security"`
Apify ApifyConfig `toml:"apify"`
Ebay EbayConfig `toml:"ebay"`
Ntfy NtfyConfig `toml:"ntfy"`
Scheduler SchedulerConfig `toml:"scheduler"`
}
// EbayConfig holds credentials for eBay's official Buy > Browse API. When set,
// eBay marketplaces are polled through the Browse API instead of an Apify
// scraper actor. ClientID is the App ID and ClientSecret is the Cert ID from
// the eBay developer keyset. Environment is "production" (default) or
// "sandbox". Like the Apify key, both credentials can be overridden at
// runtime via the Settings page.
type EbayConfig struct {
ClientID string `toml:"client_id"`
ClientSecret string `toml:"client_secret"`
Environment string `toml:"environment"`
// DailyCallLimit caps Browse API calls per day, on eBay's own quota
// clock (midnight US Pacific). Once reached, eBay polling halts until
// the next reset. Defaults to 5000 (the standard Browse API allowance).
// Set to a negative value to disable the cap.
DailyCallLimit int `toml:"daily_call_limit"`
}
type ServerConfig struct {
Port int `toml:"port"`
DBPath string `toml:"db_path"`
// SecureCookies sets the Secure attribute on the session cookie. It must
// be true in any deployment reachable over HTTPS — including behind a
// TLS-terminating proxy like Traefik, where the browser-facing leg is
// HTTPS even though Veola itself speaks plain HTTP. Defaults to true;
// set false only for local non-TLS development.
SecureCookies *bool `toml:"secure_cookies"`
}
// UseSecureCookies resolves the SecureCookies setting, defaulting to true when
// the key is absent from config.
func (c ServerConfig) UseSecureCookies() bool {
return c.SecureCookies == nil || *c.SecureCookies
}
type SecurityConfig struct {
@@ -111,6 +141,9 @@ func (c *Config) validate() error {
if c.Ntfy.DefaultTopic == "" {
c.Ntfy.DefaultTopic = "veola"
}
if c.Ebay.DailyCallLimit == 0 {
c.Ebay.DailyCallLimit = 5000
}
return nil
}

View File

@@ -213,6 +213,60 @@ func (s *Store) SetSetting(ctx context.Context, key, value string) error {
return err
}
// ============ ebay api usage ============
// ebayResetLoc is the timezone eBay's API rate limits reset in: midnight
// Pacific (it observes US DST). If the zone database is somehow unavailable
// we fall back to UTC rather than failing — main.go embeds time/tzdata so in
// practice the lookup always succeeds.
var ebayResetLoc = func() *time.Location {
loc, err := time.LoadLocation("America/Los_Angeles")
if err != nil {
return time.UTC
}
return loc
}()
// ebayUsageDay returns the date key used to bucket eBay API calls, aligned to
// eBay's own quota reset (midnight US Pacific).
func ebayUsageDay() string {
return time.Now().In(ebayResetLoc).Format("2006-01-02")
}
// EbayUsageToday returns the number of eBay Browse API calls recorded for the
// current UTC day. A missing row counts as zero.
func (s *Store) EbayUsageToday(ctx context.Context) (int, error) {
var n int
err := s.DB.QueryRowContext(ctx,
`SELECT call_count FROM ebay_api_usage WHERE usage_date = ?`, ebayUsageDay()).Scan(&n)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, err
}
return n, nil
}
// IncrementEbayUsage records one eBay Browse API call against the current UTC
// day and returns the new running total.
func (s *Store) IncrementEbayUsage(ctx context.Context) (int, error) {
day := ebayUsageDay()
_, err := s.DB.ExecContext(ctx, `
INSERT INTO ebay_api_usage (usage_date, call_count) VALUES (?, 1)
ON CONFLICT(usage_date) DO UPDATE SET call_count = call_count + 1
`, day)
if err != nil {
return 0, err
}
var n int
if err := s.DB.QueryRowContext(ctx,
`SELECT call_count FROM ebay_api_usage WHERE usage_date = ?`, day).Scan(&n); err != nil {
return 0, err
}
return n, nil
}
// ============ items ============
func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) {

View File

@@ -90,6 +90,15 @@ INSERT OR IGNORE INTO settings (key, value) VALUES
('global_poll_interval_minutes', '60'),
('match_confidence_threshold', '0.6');
-- ebay_api_usage tracks Browse API calls per day so Veola can surface
-- consumption and halt polling before the developer keyset's daily call
-- limit is exceeded. usage_date is YYYY-MM-DD in US Pacific time, matching
-- eBay's own quota reset.
CREATE TABLE IF NOT EXISTS ebay_api_usage (
usage_date TEXT PRIMARY KEY,
call_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
data BLOB NOT NULL,

227
internal/ebay/client.go Normal file
View File

@@ -0,0 +1,227 @@
// Package ebay is a thin client for eBay's official Buy > Browse API. It
// handles client-credentials OAuth2 token caching and active-listing search
// (item_summary/search). It deliberately covers only what Veola needs: a
// keyword search returning normalized listings. Sold/completed data (the
// Marketplace Insights API) is not implemented here.
package ebay
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// endpoints bundles the production/sandbox base URLs for the two APIs used.
type endpoints struct {
oauth string
browse string
}
func endpointsFor(environment string) endpoints {
if strings.EqualFold(strings.TrimSpace(environment), "sandbox") {
return endpoints{
oauth: "https://api.sandbox.ebay.com/identity/v1/oauth2/token",
browse: "https://api.sandbox.ebay.com/buy/browse/v1",
}
}
return endpoints{
oauth: "https://api.ebay.com/identity/v1/oauth2/token",
browse: "https://api.ebay.com/buy/browse/v1",
}
}
// Client is safe for concurrent use. The application access token is cached
// in memory and refreshed shortly before it expires.
type Client struct {
HTTP *http.Client
mu sync.Mutex
clientID string
clientSecret string
ends endpoints
token string
tokenExpiry time.Time
}
// New builds a client for the given keyset. environment is "production"
// (default) or "sandbox". Credentials may be empty; calls then fail fast
// with a "not configured" error.
func New(clientID, clientSecret, environment string) *Client {
return &Client{
HTTP: &http.Client{Timeout: 30 * time.Second},
clientID: clientID,
clientSecret: clientSecret,
ends: endpointsFor(environment),
}
}
// EnsureCredentials updates the keyset if it changed, discarding any cached
// token so the next call re-authenticates. The environment is fixed at
// construction time and is not changed here. Safe to call on every poll.
func (c *Client) EnsureCredentials(clientID, clientSecret string) {
c.mu.Lock()
defer c.mu.Unlock()
if clientID == c.clientID && clientSecret == c.clientSecret {
return
}
c.clientID = clientID
c.clientSecret = clientSecret
c.token = ""
c.tokenExpiry = time.Time{}
}
func (c *Client) accessToken(ctx context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.clientID == "" || c.clientSecret == "" {
return "", errors.New("ebay credentials not configured")
}
if c.token != "" && time.Now().Before(c.tokenExpiry) {
return c.token, nil
}
form := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("scope", "https://api.ebay.com/oauth/api_scope")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.ends.oauth, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
basic := base64.StdEncoding.EncodeToString([]byte(c.clientID + ":" + c.clientSecret))
req.Header.Set("Authorization", "Basic "+basic)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.HTTP.Do(req)
if err != nil {
return "", fmt.Errorf("ebay oauth: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 300 {
return "", fmt.Errorf("ebay oauth: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var tr struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
if err := json.Unmarshal(body, &tr); err != nil {
return "", fmt.Errorf("ebay oauth: decode: %w", err)
}
if tr.AccessToken == "" {
return "", errors.New("ebay oauth: empty access token")
}
c.token = tr.AccessToken
// Refresh a minute early to avoid racing the expiry.
ttl := time.Duration(tr.ExpiresIn) * time.Second
if ttl <= time.Minute {
ttl = time.Minute
}
c.tokenExpiry = time.Now().Add(ttl - time.Minute)
return c.token, nil
}
// browseItemSummary mirrors the subset of the item_summary/search response
// Veola consumes.
type browseItemSummary struct {
ItemID string `json:"itemId"`
Title string `json:"title"`
Price struct {
Value string `json:"value"`
Currency string `json:"currency"`
} `json:"price"`
ItemWebURL string `json:"itemWebUrl"`
Image struct {
ImageURL string `json:"imageUrl"`
} `json:"image"`
ThumbnailImages []struct {
ImageURL string `json:"imageUrl"`
} `json:"thumbnailImages"`
Seller struct {
Username string `json:"username"`
} `json:"seller"`
}
// Search runs one item_summary/search call and returns normalized listings.
// An empty query is rejected: the Browse API requires a non-empty q.
func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error) {
query := strings.TrimSpace(p.Query)
if query == "" {
return nil, errors.New("ebay search requires a non-empty query")
}
token, err := c.accessToken(ctx)
if err != nil {
return nil, err
}
marketplace := p.MarketplaceID
if marketplace == "" {
marketplace = "EBAY_US"
}
limit := p.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
q := url.Values{}
q.Set("q", query)
q.Set("limit", strconv.Itoa(limit))
if f := buyingOptionsFilter(p.ListingType); f != "" {
q.Set("filter", f)
}
reqURL := c.ends.browse + "/item_summary/search?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-EBAY-C-MARKETPLACE-ID", marketplace)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("ebay browse: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("ebay browse: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var sr struct {
ItemSummaries []browseItemSummary `json:"itemSummaries"`
}
if err := json.Unmarshal(body, &sr); err != nil {
return nil, fmt.Errorf("ebay browse: decode: %w", err)
}
out := make([]Listing, 0, len(sr.ItemSummaries))
for _, s := range sr.ItemSummaries {
price, _ := strconv.ParseFloat(strings.TrimSpace(s.Price.Value), 64)
img := s.Image.ImageURL
if img == "" && len(s.ThumbnailImages) > 0 {
img = s.ThumbnailImages[0].ImageURL
}
store := "ebay"
if s.Seller.Username != "" {
store = "ebay (" + s.Seller.Username + ")"
}
out = append(out, Listing{
Title: s.Title,
Price: price,
Currency: s.Price.Currency,
URL: s.ItemWebURL,
Store: store,
ImageURL: img,
})
}
return out, nil
}

View File

@@ -0,0 +1,45 @@
package ebay
import "testing"
func TestMarketplaceID(t *testing.T) {
cases := map[string]string{
"ebay.com": "EBAY_US",
"ebay.co.uk": "EBAY_GB",
"ebay.de": "EBAY_DE",
"ebay.com.au": "EBAY_AU",
"EBAY.CA": "EBAY_CA",
"ebay": "EBAY_US",
"weird-market": "EBAY_US",
" ebay.it ": "EBAY_IT",
}
for in, want := range cases {
if got := MarketplaceID(in); got != want {
t.Errorf("MarketplaceID(%q) = %q, want %q", in, got, want)
}
}
}
func TestIsEbayMarketplace(t *testing.T) {
if !IsEbayMarketplace("ebay.co.uk") {
t.Error("ebay.co.uk should be an eBay marketplace")
}
if IsEbayMarketplace("yahoo-auctions-jp") {
t.Error("yahoo should not be an eBay marketplace")
}
}
func TestBuyingOptionsFilter(t *testing.T) {
cases := map[string]string{
"": "",
"all": "",
"bin": "buyingOptions:{FIXED_PRICE}",
"buy_it_now": "buyingOptions:{FIXED_PRICE}",
"auction": "buyingOptions:{AUCTION}",
}
for in, want := range cases {
if got := buyingOptionsFilter(in); got != want {
t.Errorf("buyingOptionsFilter(%q) = %q, want %q", in, got, want)
}
}
}

94
internal/ebay/types.go Normal file
View File

@@ -0,0 +1,94 @@
package ebay
import "strings"
// 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
// scheduler plan, mirroring how Apify actor inputs are carried.
type SearchParams struct {
// MarketplaceID is an eBay marketplace identifier such as EBAY_US.
MarketplaceID string
// Query is the keyword search string. Required; the Browse API rejects
// an empty q.
Query string
// ListingType is Veola's vocabulary ("all", "bin"/"buy_it_now",
// "auction"); it is mapped to a buyingOptions filter.
ListingType string
// Limit caps the number of results requested (Browse API max is 200).
Limit int
}
// Listing is one normalized active eBay listing. The scheduler converts these
// into the shared apify.UnifiedResult shape so the rest of the pipeline
// (dedup, filter, alert) is provider-agnostic.
type Listing struct {
Title string
Price float64
Currency string
URL string
Store string
ImageURL string
}
// MarketplaceID maps a Veola marketplace string (e.g. "ebay.com",
// "ebay.co.uk") to an eBay Browse API marketplace identifier. Unknown or
// bare "ebay" values fall back to EBAY_US.
func MarketplaceID(marketplace string) string {
m := strings.ToLower(strings.TrimSpace(marketplace))
switch {
case strings.Contains(m, "ebay.co.uk"):
return "EBAY_GB"
case strings.Contains(m, "ebay.de"):
return "EBAY_DE"
case strings.Contains(m, "ebay.com.au"):
return "EBAY_AU"
case strings.Contains(m, "ebay.ca"):
return "EBAY_CA"
case strings.Contains(m, "ebay.fr"):
return "EBAY_FR"
case strings.Contains(m, "ebay.it"):
return "EBAY_IT"
case strings.Contains(m, "ebay.es"):
return "EBAY_ES"
case strings.Contains(m, "ebay.at"):
return "EBAY_AT"
case strings.Contains(m, "ebay.ch"):
return "EBAY_CH"
case strings.Contains(m, "ebay.ie"):
return "EBAY_IE"
case strings.Contains(m, "ebay.nl"):
return "EBAY_NL"
case strings.Contains(m, "ebay.com.hk"):
return "EBAY_HK"
case strings.Contains(m, "ebay.com.sg"):
return "EBAY_SG"
case strings.Contains(m, "ebay.com.my"):
return "EBAY_MY"
case strings.Contains(m, "ebay.ph"):
return "EBAY_PH"
case strings.Contains(m, "ebay.pl"):
return "EBAY_PL"
default:
// "ebay.com" and any bare/unknown eBay marketplace.
return "EBAY_US"
}
}
// IsEbayMarketplace reports whether a Veola marketplace string should be
// polled through the official eBay Browse API.
func IsEbayMarketplace(marketplace string) bool {
return strings.Contains(strings.ToLower(marketplace), "ebay")
}
// buyingOptionsFilter maps Veola's listing-type vocabulary to the Browse API
// "filter" query parameter. An empty string means no filter ("all").
func buyingOptionsFilter(listingType string) string {
switch strings.ToLower(strings.TrimSpace(listingType)) {
case "bin", "buy_it_now", "fixed_price":
return "buyingOptions:{FIXED_PRICE}"
case "auction":
return "buyingOptions:{AUCTION}"
default:
return ""
}
}

View File

@@ -24,10 +24,11 @@ func (a *App) GetDashboardRefresh(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Render only the inner block by reusing the full body component; the
// outer hx-swap="outerHTML" replaces the same wrapper. The full Dashboard
// template is overkill but keeps a single source of truth.
render(w, r, templates.Dashboard(d))
// Render ONLY the inner body. The hx-swap="outerHTML" on DashboardBody's
// root div replaces it with this fresh copy. Rendering templates.Dashboard
// here would return the whole Layout — sidebar included — nested inside
// the div, producing a duplicate nav bar on every refresh.
render(w, r, templates.DashboardBody(d))
}
func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {

View File

@@ -12,6 +12,7 @@ import (
"github.com/a-h/templ"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"veola/internal/apify"
"veola/internal/auth"
@@ -43,6 +44,14 @@ func New(cfg *config.Config, store *db.Store, am *auth.Manager, ap *apify.Client
// Routes returns the chi router with everything wired up.
func (a *App) Routes() http.Handler {
r := chi.NewRouter()
// Applied to everything, including static assets and error responses.
// RealIP rewrites RemoteAddr from X-Forwarded-For — safe only because
// Veola is expected to sit behind a trusted proxy (Traefik) that sets
// it; Traefik must be configured to strip client-supplied values.
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(securityHeaders)
fs := http.FileServer(http.Dir("./static"))
r.Handle("/static/*", http.StripPrefix("/static/", fs))
@@ -85,6 +94,7 @@ func (a *App) Routes() http.Handler {
r.With(a.Auth.CSRFProtect).Post("/settings/password", a.PostPasswordChange)
r.With(a.Auth.CSRFProtect).Post("/settings/test-ntfy", a.PostTestNtfy)
r.With(a.Auth.CSRFProtect).Post("/settings/test-apify", a.PostTestApify)
r.With(a.Auth.CSRFProtect).Post("/settings/test-ebay", a.PostTestEbay)
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users", a.PostCreateUser)
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/delete", a.PostDeleteUser)
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/reset-password", a.PostResetPassword)
@@ -114,6 +124,38 @@ func (a *App) setupGate(next http.Handler) http.Handler {
})
}
// securityHeaders sets defensive response headers on every request.
//
// Scripts and styles are restricted to 'self': Tailwind is vendored
// (static/css/tailwind.css), htmx and Chart.js are vendored, and the only
// inline <script> (the price chart) was externalized to static/js. The sole
// remote origins are Google Fonts (stylesheet + font files), per the agreed
// deviation to keep fonts on a CDN.
//
// img-src additionally allows any https: origin: listing thumbnails come from
// an open-ended set of marketplace CDNs (eBay, Amazon, Mercari, Yahoo, ...)
// that cannot be enumerated. Images execute no code, so this is a low-risk
// relaxation while script/style/connect stay locked to 'self'.
//
// HSTS is intentionally omitted: it belongs at the TLS-terminating proxy.
func securityHeaders(next http.Handler) http.Handler {
const csp = "default-src 'self'; " +
"img-src 'self' data: https:; " +
"font-src 'self' https://fonts.gstatic.com; " +
"style-src 'self' https://fonts.googleapis.com; " +
"script-src 'self'; " +
"connect-src 'self'; " +
"frame-ancestors 'none'; base-uri 'self'; object-src 'none'; form-action 'self'"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy", csp)
next.ServeHTTP(w, r)
})
}
func isStaticPath(p string) bool {
return len(p) >= 8 && p[:8] == "/static/"
}

View File

@@ -2,13 +2,13 @@ package handlers
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
"time"
"veola/internal/apify"
"veola/internal/models"
@@ -261,46 +261,16 @@ func (a *App) runPreview(ctx context.Context, it models.Item) ([]apify.UnifiedRe
var merged []apify.UnifiedResult
primarySource := ""
for _, p := range plans {
actorID := p.ActorID()
if actorID == "" {
continue
}
raw, err := a.Apify.Run(ctx, actorID, p.Input())
decoded, err := a.Scheduler.ExecutePlan(ctx, p)
if err != nil {
slog.Warn("preview run failed", "actor", actorID, "query", p.Query(), "err", err)
slog.Warn("preview plan failed",
"provider", p.Provider(),
"marketplace", p.Marketplace(),
"query", p.Query(),
"err", err,
)
continue
}
decoded, _ := apify.Decode(raw, p.Source())
for i := range decoded {
decoded[i].MatchedQuery = p.Query()
}
usable := 0
for _, r := range decoded {
if r.URL != "" && r.Price > 0 {
usable++
}
}
slog.Info("preview decoded",
"marketplace", previewMarket,
"actor", actorID,
"query", p.Query(),
"raw", len(raw),
"decoded", len(decoded),
"usable", usable,
)
if usable == 0 && len(raw) > 0 {
var sample map[string]any
if err := json.Unmarshal(raw[0], &sample); err == nil {
ks := make([]string, 0, len(sample))
for k := range sample {
ks = append(ks, k)
}
slog.Warn("preview decoded zero usable rows; raw item keys",
"actor", actorID,
"keys", ks,
)
}
}
merged = append(merged, decoded...)
if primarySource == "" {
primarySource = p.Source()
@@ -431,9 +401,40 @@ func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
go a.Scheduler.RunPoll(context.Background(), *it)
// Re-render the row immediately so HTMX has something to swap in.
render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context())))
// Run synchronously so the response reflects the finished poll. Bounded so
// a slow Apify actor run can't tie the request up indefinitely (eBay
// Browse API polls finish in seconds, well within this). Detached from the
// request context so a client disconnect mid-run doesn't abort DB writes.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
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)
return
}
// The results page asks for a refreshed listing table; the items list
// asks for a refreshed row. Both POST to this same endpoint.
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
}
render(w, r, templates.ItemRow(*fresh, a.Auth.CSRFToken(r.Context())))
}
func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) {

View File

@@ -21,20 +21,29 @@ func (a *App) GetItemResults(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
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)
return
}
render(w, r, templates.ItemResults(d))
}
order := r.URL.Query().Get("order")
// buildItemResultsData assembles the per-item results view: paginated results,
// price history, badge, and chart JSON. Shared by GetItemResults and the
// "Run Now" handler so both render identical data.
func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, order string) (templates.ItemResultsData, error) {
if order == "" {
order = "found_desc"
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
total, err := a.Store.CountResults(r.Context(), id)
total, err := a.Store.CountResults(r.Context(), it.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
return templates.ItemResultsData{}, err
}
totalPages := (total + resultsPerPage - 1) / resultsPerPage
if totalPages < 1 {
@@ -45,36 +54,31 @@ func (a *App) GetItemResults(w http.ResponseWriter, r *http.Request) {
}
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
ItemID: id,
ItemID: it.ID,
Limit: resultsPerPage,
Offset: (page - 1) * resultsPerPage,
Order: order,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
return templates.ItemResultsData{}, err
}
history, err := a.Store.ListPriceHistory(r.Context(), id)
history, err := a.Store.ListPriceHistory(r.Context(), it.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
return templates.ItemResultsData{}, err
}
badge := scheduler.PickBadge(*it, history, time.Now())
chart := buildChartJSON(history)
render(w, r, templates.ItemResults(templates.ItemResultsData{
return templates.ItemResultsData{
Page: a.page(r, it.Name, "items"),
Item: *it,
Badge: badge,
Badge: scheduler.PickBadge(*it, history, time.Now()),
History: history,
Results: results,
Page_: page,
TotalPages: totalPages,
Order: order,
HistoryChartJSON: chart,
}))
HistoryChartJSON: buildChartJSON(history),
}, nil
}
func buildChartJSON(history []models.PricePoint) string {

View File

@@ -3,10 +3,12 @@ package handlers
import (
"fmt"
"net/http"
"strconv"
"strings"
"veola/internal/apify"
"veola/internal/auth"
"veola/internal/ebay"
"veola/internal/models"
"veola/internal/ntfy"
"veola/templates"
@@ -14,6 +16,9 @@ import (
var settingsKeys = []string{
"apify_api_key",
"ebay_client_id",
"ebay_client_secret",
"ebay_daily_call_limit",
"ntfy_base_url",
"ntfy_default_topic",
"ntfy_token",
@@ -31,11 +36,14 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
}
users, _ := a.Store.ListUsers(r.Context())
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,
Page: a.page(r, "Settings", "settings"),
Values: values,
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
Users: users,
EbayUsedToday: ebayUsed,
EbayDailyLimit: ebayLimit,
}, nil
}
@@ -193,3 +201,90 @@ func (a *App) PostTestApify(w http.ResponseWriter, r *http.Request) {
}
render(w, r, templates.Settings(d))
}
func (a *App) PostTestEbay(w http.ResponseWriter, r *http.Request) {
cur := auth.CurrentUserFromRequest(r)
if cur == nil || cur.Role != models.RoleAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
d, err := a.settingsData(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Settings-table values win over config.toml. Both paths are trimmed:
// a stray newline in the TOML would otherwise reach eBay verbatim.
clientID := strings.TrimSpace(d.Values["ebay_client_id"])
if clientID == "" {
clientID = strings.TrimSpace(a.Cfg.Ebay.ClientID)
}
clientSecret := strings.TrimSpace(d.Values["ebay_client_secret"])
if clientSecret == "" {
clientSecret = strings.TrimSpace(a.Cfg.Ebay.ClientSecret)
}
if clientID == "" || clientSecret == "" {
d.TestEbayOK = "Set the eBay App ID and Cert ID first."
render(w, r, templates.Settings(d))
return
}
if d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit {
d.TestEbayOK = fmt.Sprintf("Daily eBay API call limit reached (%d/%d). Test skipped.", d.EbayUsedToday, d.EbayDailyLimit)
render(w, r, templates.Settings(d))
return
}
env := "production"
if strings.EqualFold(strings.TrimSpace(a.Cfg.Ebay.Environment), "sandbox") {
env = "sandbox"
}
// Echo back exactly what was sent (App ID masked, Cert ID length only) so
// a failure points at the inputs, not just "it failed".
inputs := fmt.Sprintf("%s, App ID %s, Cert ID %d chars", env, maskID(clientID), len(clientSecret))
client := ebay.New(clientID, clientSecret, a.Cfg.Ebay.Environment)
listings, err := client.Search(r.Context(), ebay.SearchParams{
MarketplaceID: "EBAY_US",
Query: "test",
Limit: 1,
})
// A real call was made; count it against the daily allowance.
if n, incErr := a.Store.IncrementEbayUsage(r.Context()); incErr == nil {
d.EbayUsedToday = n
}
if err != nil {
msg := fmt.Sprintf("eBay test failed (%s): %s", inputs, err.Error())
if ks := ebayKeysetEnv(clientID); ks != "" && ks != env {
msg += fmt.Sprintf(" — the App ID looks like a %s keyset, but environment is %q. Set environment = %q in the [ebay] config block (or use your %s keyset).", ks, env, ks, env)
}
d.TestEbayOK = msg
} else {
d.TestEbayOK = fmt.Sprintf("eBay Browse API reachable (%s). Returned %d item(s).", inputs, len(listings))
}
render(w, r, templates.Settings(d))
}
// maskID returns a fingerprint of a credential for display: enough of the head
// to recognize it, the rest elided. Used only for the App ID (the non-secret
// half of the OAuth pair) — never for the Cert ID.
func maskID(s string) string {
if len(s) <= 12 {
return strings.Repeat("•", len(s))
}
return s[:12] + "…(" + strconv.Itoa(len(s)) + " chars)"
}
// ebayKeysetEnv guesses which environment an eBay App ID belongs to from the
// SBX/PRD marker eBay embeds in it (e.g. "Name-app-PRD-1a2b..."). Returns ""
// when no marker is present.
func ebayKeysetEnv(clientID string) string {
up := strings.ToUpper(clientID)
switch {
case strings.Contains(up, "-SBX-"):
return "sandbox"
case strings.Contains(up, "-PRD-"):
return "production"
default:
return ""
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
"time"
@@ -13,14 +14,23 @@ import (
"veola/internal/apify"
"veola/internal/config"
"veola/internal/db"
"veola/internal/ebay"
"veola/internal/models"
"veola/internal/ntfy"
)
// Provider labels distinguish how a plan is executed: through an Apify actor
// run, or through eBay's official Browse API.
const (
providerApify = "apify"
providerEbay = "ebay"
)
type Scheduler struct {
cfg *config.Config
store *db.Store
apify *apify.Client
ebay *ebay.Client
ntfy *ntfy.Client
cron *cron.Cron
@@ -37,6 +47,7 @@ func New(cfg *config.Config, store *db.Store, ap *apify.Client, nt *ntfy.Client)
cfg: cfg,
store: store,
apify: ap,
ebay: ebay.New(cfg.Ebay.ClientID, cfg.Ebay.ClientSecret, cfg.Ebay.Environment),
ntfy: nt,
cron: cron.New(),
entries: make(map[int64]cron.EntryID),
@@ -136,52 +147,16 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
var errs []string
successes := 0
for _, p := range plans {
if p.actorID == "" {
errs = append(errs, fmt.Sprintf("%s: no actor configured", p.marketplace))
continue
}
raw, err := apifyClient.Run(ctx, p.actorID, p.input)
decoded, err := s.ExecutePlan(ctx, p)
if err != nil {
label := p.marketplace
if p.query != "" {
label = fmt.Sprintf("query %q on %s", p.query, p.marketplace)
}
errs = append(errs, fmt.Sprintf("%s: %s", label, err.Error()))
slog.Error("apify run failed", "item_id", it.ID, "marketplace", p.marketplace, "query", p.query, "err", err)
slog.Error("plan failed", "item_id", it.ID, "provider", p.provider, "marketplace", p.marketplace, "query", p.query, "err", err)
continue
}
decoded, _ := apify.Decode(raw, p.source)
usable := 0
for i := range decoded {
decoded[i].MatchedQuery = p.query
if decoded[i].URL != "" && decoded[i].Price > 0 {
usable++
}
}
slog.Info("apify run decoded",
"item_id", it.ID,
"marketplace", p.marketplace,
"query", p.query,
"actor", p.actorID,
"raw", len(raw),
"decoded", len(decoded),
"usable", usable,
)
if usable == 0 && len(raw) > 0 {
var sample map[string]any
if err := jsonUnmarshal(raw[0], &sample); err == nil {
keys := make([]string, 0, len(sample))
for k := range sample {
keys = append(keys, k)
}
slog.Warn("decoded zero usable rows; raw item keys",
"item_id", it.ID,
"marketplace", p.marketplace,
"actor", p.actorID,
"keys", keys,
)
}
}
results = append(results, decoded...)
successes++
}
@@ -322,6 +297,103 @@ func (s *Scheduler) apifyClient(ctx context.Context) *apify.Client {
return apify.New(key)
}
// ebayClient returns the shared eBay client with credentials refreshed from
// settings (falling back to config.toml). The client caches its OAuth token
// in memory, so the same instance is reused across polls; credentials are
// only re-applied when they actually change.
func (s *Scheduler) ebayClient(ctx context.Context) *ebay.Client {
id := s.cfg.Ebay.ClientID
secret := s.cfg.Ebay.ClientSecret
if v, _ := s.store.GetSetting(ctx, "ebay_client_id"); v != "" {
id = v
}
if v, _ := s.store.GetSetting(ctx, "ebay_client_secret"); v != "" {
secret = v
}
s.ebay.EnsureCredentials(id, secret)
return s.ebay
}
// EbayUsage returns the number of eBay Browse API calls made so far today and
// the configured daily limit. A limit <= 0 means uncapped. Settings override
// config.toml for the limit, mirroring how credentials are resolved.
func (s *Scheduler) EbayUsage(ctx context.Context) (used, limit int) {
used, _ = s.store.EbayUsageToday(ctx)
limit = s.cfg.Ebay.DailyCallLimit
if v, _ := s.store.GetSetting(ctx, "ebay_daily_call_limit"); v != "" {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
limit = n
}
}
return used, limit
}
// ExecutePlan runs one plan and returns decoded, provider-agnostic results
// with MatchedQuery already stamped. eBay plans go through the official
// Browse API; all other plans run an Apify actor. Callers handle per-plan
// errors without poisoning sibling plans.
func (s *Scheduler) ExecutePlan(ctx context.Context, p actorPlan) ([]apify.UnifiedResult, error) {
var decoded []apify.UnifiedResult
switch p.provider {
case providerEbay:
sp, ok := p.input.(ebay.SearchParams)
if !ok {
return nil, fmt.Errorf("ebay plan has wrong input type %T", p.input)
}
used, limit := s.EbayUsage(ctx)
if limit > 0 && used >= limit {
return nil, fmt.Errorf("ebay daily API call limit reached (%d/%d); polling halted until the next reset (midnight US Pacific)", used, limit)
}
listings, err := s.ebayClient(ctx).Search(ctx, sp)
// The call hit eBay (or at least was attempted against it) whether
// or not it succeeded, so it counts against the daily allowance.
if n, incErr := s.store.IncrementEbayUsage(ctx); incErr != nil {
slog.Error("ebay usage increment failed", "err", incErr)
} else if limit > 0 && n >= limit {
slog.Warn("ebay daily API call limit reached", "used", n, "limit", limit)
}
if err != nil {
return nil, err
}
decoded = make([]apify.UnifiedResult, 0, len(listings))
for _, l := range listings {
decoded = append(decoded, apify.UnifiedResult{
Title: l.Title,
Price: l.Price,
Currency: l.Currency,
URL: l.URL,
Store: l.Store,
ImageURL: l.ImageURL,
Source: apify.SourceActiveEbay,
})
}
default:
if p.actorID == "" {
return nil, fmt.Errorf("no actor configured for %s", p.marketplace)
}
raw, err := s.apifyClient(ctx).Run(ctx, p.actorID, p.input)
if err != nil {
return nil, err
}
decoded, _ = apify.Decode(raw, p.source)
}
usable := 0
for i := range decoded {
decoded[i].MatchedQuery = p.query
if decoded[i].URL != "" && decoded[i].Price > 0 {
usable++
}
}
slog.Info("plan executed",
"provider", p.provider,
"marketplace", p.marketplace,
"query", p.query,
"decoded", len(decoded),
"usable", usable,
)
return decoded, nil
}
func (s *Scheduler) sendNotification(ctx context.Context, it models.Item, r apify.UnifiedResult) error {
tags := []string{"mag"}
if it.TargetPrice != nil && r.Price <= *it.TargetPrice {
@@ -398,6 +470,7 @@ func (s *Scheduler) BuildPreviewInputs(it models.Item) []actorPlan {
type actorPlan struct {
marketplace string
source string
provider string
actorID string
query string
input any
@@ -418,6 +491,9 @@ func (p actorPlan) Query() string { return p.query }
// Input returns the actor input payload as expected by apify.Client.Run.
func (p actorPlan) Input() any { return p.input }
// Provider returns "apify" or "ebay" — how this plan is executed.
func (p actorPlan) Provider() string { return p.provider }
// buildAllInputs returns one actor plan per (alias × marketplace) for the item.
// For URL-only items (no aliases), produces one plan per marketplace with an
// empty query string.
@@ -447,27 +523,51 @@ func (s *Scheduler) buildInputsForQuery(it models.Item, query string, markets []
switch {
case strings.Contains(mk, "yahoo") || strings.Contains(url, "yahoo.co.jp"):
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.YahooAuctionsJP)
plans = append(plans, actorPlan{m, apify.SourceYahooJP, actorID, query, apify.YahooAuctionsJPInput{
SearchTerm: query,
MaxPages: 1,
}})
plans = append(plans, actorPlan{
marketplace: m, source: apify.SourceYahooJP, provider: providerApify,
actorID: actorID, query: query,
input: apify.YahooAuctionsJPInput{SearchTerm: query, MaxPages: 1},
})
case strings.Contains(mk, "mercari") || strings.Contains(url, "mercari"):
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.MercariJP)
plans = append(plans, actorPlan{m, apify.SourceMercariJP, actorID, query, apify.MercariJPInput{
SearchKeywords: []string{query},
Status: "on_sale",
MaxResults: 30,
}})
plans = append(plans, actorPlan{
marketplace: m, source: apify.SourceMercariJP, provider: providerApify,
actorID: actorID, query: query,
input: apify.MercariJPInput{
SearchKeywords: []string{query},
Status: "on_sale",
MaxResults: 30,
},
})
case ebay.IsEbayMarketplace(mk):
// eBay marketplaces are polled through eBay's official Browse
// API, not an Apify scraper actor.
plans = append(plans, actorPlan{
marketplace: m, source: apify.SourceActiveEbay, provider: providerEbay,
query: query,
input: ebay.SearchParams{
MarketplaceID: ebay.MarketplaceID(mk),
Query: query,
ListingType: it.ListingType,
Limit: 30,
},
})
default:
// Non-eBay custom marketplaces still fall back to the Apify
// active-listings actor.
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.ActiveListings)
plans = append(plans, actorPlan{m, apify.SourceActiveEbay, actorID, query, apify.ActiveListingInput{
SearchQueries: []string{query},
MaxProductsPerSearch: 30,
MaxSearchPages: 1,
Sort: "best_match",
ListingType: mapListingType(it.ListingType),
ProxyConfiguration: s.proxyConfig(),
}})
plans = append(plans, actorPlan{
marketplace: m, source: apify.SourceActiveEbay, provider: providerApify,
actorID: actorID, query: query,
input: apify.ActiveListingInput{
SearchQueries: []string{query},
MaxProductsPerSearch: 30,
MaxSearchPages: 1,
Sort: "best_match",
ListingType: mapListingType(it.ListingType),
ProxyConfiguration: s.proxyConfig(),
},
})
}
}
return plans

View File

@@ -11,6 +11,9 @@ import (
"os/signal"
"syscall"
"time"
// Embed the timezone database so eBay's Pacific-time quota reset resolves
// correctly even on minimal hosts without system zoneinfo.
_ "time/tzdata"
"veola/internal/apify"
"veola/internal/auth"
@@ -50,7 +53,7 @@ func run(configPath string) error {
defer sqlDB.Close()
store := db.NewStore(sqlDB, key)
authMgr, err := auth.NewManager(sqlDB, store, cfg.Security.SessionSecret)
authMgr, err := auth.NewManager(sqlDB, store, cfg.Security.SessionSecret, cfg.Server.UseSecureCookies())
if err != nil {
return fmt.Errorf("auth manager: %w", err)
}

View File

@@ -193,11 +193,6 @@ table.v-table tr:hover td { background: rgba(255,255,255,0.03); }
margin-bottom: 1rem;
}
/* htmx indicator: hidden by default, visible during in-flight requests. */
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator { display: inline-flex; }
.v-spinner {
display: inline-block;
width: 14px; height: 14px;
@@ -207,3 +202,11 @@ table.v-table tr:hover td { background: rgba(255,255,255,0.03); }
animation: v-spin 0.8s linear infinite;
}
@keyframes v-spin { to { transform: rotate(360deg); } }
/* htmx indicator: hidden by default, visible during in-flight requests.
Declared AFTER .v-spinner on purpose: an element carrying both classes
(e.g. <span class="v-spinner htmx-indicator">) must stay hidden until a
request is active, and equal-specificity rules resolve by source order. */
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator { display: inline-flex; }

6
static/css/input.css Normal file
View File

@@ -0,0 +1,6 @@
/* Tailwind entry point. Compiled by the standalone CLI into tailwind.css:
see the Makefile `css` target. The hand-written Veola component layer
lives in app.css and is loaded separately, so editing it needs no rebuild. */
@tailwind base;
@tailwind components;
@tailwind utilities;

2
static/css/tailwind.css Normal file

File diff suppressed because one or more lines are too long

35
static/js/price-chart.js Normal file
View File

@@ -0,0 +1,35 @@
// Renders the price-history line chart on the per-item results page.
// Chart data is read from the canvas's data-chart attribute: templ
// interpolates attribute values but treats <script> element contents as raw
// text, so the JSON cannot live in an inline <script> block. Loaded after
// chart.umd.min.js with the #price-chart canvas already in the DOM above it.
(function () {
var canvas = document.getElementById('price-chart');
if (!canvas || !canvas.dataset.chart || typeof Chart === 'undefined') {
return;
}
var data = JSON.parse(canvas.dataset.chart);
new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
labels: data.labels,
datasets: [{
label: 'Best price',
data: data.points,
borderColor: '#00e4a4',
backgroundColor: 'rgba(0,228,164,0.15)',
pointBackgroundColor: '#e84040',
pointRadius: 3,
tension: 0.25,
fill: true
}]
},
options: {
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)' } }
},
plugins: { legend: { labels: { color: '#ffffff' } } }
}
});
})();

13
tailwind.config.js Normal file
View File

@@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
// Scans the .templ sources for utility classes. Custom v-* component classes
// live in static/css/app.css, not here, so they need no safelisting. If a
// utility class is ever built dynamically in Go rather than written as a
// literal in a template, add it to `safelist` below.
module.exports = {
content: ["./templates/**/*.templ"],
safelist: [],
theme: {
extend: {},
},
plugins: [],
};

View File

@@ -34,7 +34,12 @@ type AlertRow struct {
FoundAt time.Time
}
templ dashboardBody(d DashboardData) {
// DashboardBody is the self-refreshing inner block. It is both the initial
// render target (inside Layout) and the response to /dashboard/refresh, so the
// hx-swap="outerHTML" on its root div replaces it with a fresh copy of itself.
// GetDashboardRefresh must render THIS, not Dashboard — rendering the full
// Layout would inject a nested page (and a duplicate sidebar) into the div.
templ DashboardBody(d DashboardData) {
<div hx-get="/dashboard/refresh" hx-trigger="every 60s" hx-swap="outerHTML">
<h1 class="text-3xl font-semibold mb-6">Dashboard</h1>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
@@ -111,7 +116,7 @@ templ statCard(label, value, sub string) {
}
templ Dashboard(d DashboardData) {
@Layout(d.Page, dashboardBody(d))
@Layout(d.Page, DashboardBody(d))
}
// Helpers used by multiple templates.

View File

@@ -42,7 +42,12 @@ type AlertRow struct {
FoundAt time.Time
}
func dashboardBody(d DashboardData) templ.Component {
// DashboardBody is the self-refreshing inner block. It is both the initial
// render target (inside Layout) and the response to /dashboard/refresh, so the
// hx-swap="outerHTML" on its root div replaces it with a fresh copy of itself.
// GetDashboardRefresh must render THIS, not Dashboard — rendering the full
// Layout would inject a nested page (and a duplicate sidebar) into the div.
func DashboardBody(d DashboardData) 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 {
@@ -90,7 +95,7 @@ func dashboardBody(d DashboardData) templ.Component {
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: 49, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 54, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -103,7 +108,7 @@ func dashboardBody(d DashboardData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.PricedItemCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 50, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 55, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -121,7 +126,7 @@ func dashboardBody(d DashboardData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 52, Col: 103}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 57, Col: 103}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -139,7 +144,7 @@ func dashboardBody(d DashboardData) templ.Component {
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: 57, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 62, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -152,7 +157,7 @@ func dashboardBody(d DashboardData) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.SavedItemCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 58, Col: 88}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 63, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -180,7 +185,7 @@ func dashboardBody(d DashboardData) templ.Component {
var templ_7745c5c3_Var7 templ.SafeURL
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 74, Col: 80}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 79, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -193,7 +198,7 @@ func dashboardBody(d DashboardData) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 74, Col: 95}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 79, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -206,7 +211,7 @@ func dashboardBody(d DashboardData) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 75, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 80, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -219,7 +224,7 @@ func dashboardBody(d DashboardData) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 76, Col: 23}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 81, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -232,7 +237,7 @@ func dashboardBody(d DashboardData) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 77, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 82, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -270,7 +275,7 @@ func dashboardBody(d DashboardData) templ.Component {
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(a.ItemName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 92, Col: 26}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 97, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -283,7 +288,7 @@ func dashboardBody(d DashboardData) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(a.Price, a.Currency))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 93, Col: 78}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 98, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -335,7 +340,7 @@ func statCard(label, value, sub string) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 105, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 110, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -348,7 +353,7 @@ func statCard(label, value, sub string) templ.Component {
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: 106, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 111, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -366,7 +371,7 @@ func statCard(label, value, sub string) templ.Component {
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(sub)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 113, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
@@ -406,7 +411,7 @@ func Dashboard(d DashboardData) templ.Component {
templ_7745c5c3_Var18 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Layout(d.Page, dashboardBody(d)).Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = Layout(d.Page, DashboardBody(d)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View File

@@ -124,9 +124,12 @@ templ itemRow(it models.Item, csrf string) {
}
</button>
</form>
<form class="inline" hx-post={ fmt.Sprintf("/items/%d/run", it.ID) } hx-target={ fmt.Sprintf("#item-row-%d", it.ID) } hx-swap="outerHTML">
<form class="inline" hx-post={ fmt.Sprintf("/items/%d/run", it.ID) } hx-target={ fmt.Sprintf("#item-row-%d", it.ID) } hx-swap="outerHTML" hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value={ csrf }/>
<button class="v-btn-ghost text-sm" type="submit">Run Now</button>
<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={ templ.SafeURL(fmt.Sprintf("/items/%d/edit", it.ID)) }>Edit</a>
<form class="inline" hx-post={ fmt.Sprintf("/items/%d/delete", it.ID) } hx-target={ fmt.Sprintf("#item-row-%d", it.ID) } hx-swap="outerHTML" hx-confirm="Delete this item?">

View File

@@ -530,7 +530,7 @@ func itemRow(it models.Item, csrf string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
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
}
@@ -543,14 +543,14 @@ func itemRow(it models.Item, csrf string) templ.Component {
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</button></form><a class=\"v-btn-ghost text-sm\" href=\"")
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: 131, Col: 92}
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 {
@@ -563,7 +563,7 @@ func itemRow(it models.Item, csrf string) templ.Component {
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, 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: 132, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 135, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil {
@@ -576,7 +576,7 @@ func itemRow(it models.Item, csrf string) templ.Component {
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: 132, Col: 121}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 135, Col: 121}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil {
@@ -589,7 +589,7 @@ func itemRow(it models.Item, csrf string) templ.Component {
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: 133, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 136, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil {

View File

@@ -18,10 +18,10 @@ templ head(title string) {
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ title } · Veola</title>
<script src="https://cdn.tailwindcss.com"></script>
<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>

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><script src=\"https://cdn.tailwindcss.com\"></script><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/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\"><script src=\"/static/vendor/htmx.min.js\" defer></script></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View File

@@ -17,6 +17,10 @@ type ItemResultsData struct {
TotalPages int
Order string
HistoryChartJSON string
// RunMsg / RunError carry feedback from a "Run Now" poll. Both empty on a
// normal page load; PostRunItem sets exactly one.
RunMsg string
RunError string
}
type BadgeData struct {
@@ -61,8 +65,17 @@ templ itemResultsBody(d ItemResultsData) {
<span class={ "v-badge", d.Badge.Class }>{ d.Badge.Label }</span>
</div>
}
<form class="mt-3" hx-post={ fmt.Sprintf("/items/%d/run", d.Item.ID) } hx-swap="none">
<form
class="mt-3 flex items-center gap-2 justify-end"
hx-post={ fmt.Sprintf("/items/%d/run", d.Item.ID) }
hx-target="#item-results-table"
hx-swap="outerHTML"
hx-disabled-elt="find button"
>
<input type="hidden" name="csrf_token" value={ d.CSRFToken }/>
<input type="hidden" name="from" value="results"/>
<span class="v-spinner htmx-indicator"></span>
<span class="v-muted text-sm htmx-indicator">Running...</span>
<button class="v-btn" type="submit">Run Now</button>
</form>
</div>
@@ -73,41 +86,30 @@ templ itemResultsBody(d ItemResultsData) {
if len(d.History) < 2 {
<div class="v-muted">Not enough history yet.</div>
} else {
<canvas id="price-chart" height="120"></canvas>
// 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>
<script src="/static/vendor/chart.umd.min.js"></script>
<script id="price-data" type="application/json">{ d.HistoryChartJSON }</script>
<script>
(function(){
var data = JSON.parse(document.getElementById('price-data').textContent);
var ctx = document.getElementById('price-chart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: data.labels,
datasets: [{
label: 'Best price',
data: data.points,
borderColor: '#00e4a4',
backgroundColor: 'rgba(0,228,164,0.15)',
pointBackgroundColor: '#e84040',
pointRadius: 3,
tension: 0.25,
fill: true
}]
},
options: {
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)' } }
},
plugins: { legend: { labels: { color: '#ffffff' } } }
}
});
})();
</script>
<script src="/static/js/price-chart.js"></script>
}
</div>
@ItemResultsTable(d)
</div>
}
// ItemResultsTable is the results listing + pagination, plus optional "Run
// Now" feedback. It is both part of the initial page (via itemResultsBody)
// and the standalone response to POST /items/{id}/run from the results page,
// so the Run Now button targets #item-results-table with hx-swap="outerHTML".
templ ItemResultsTable(d ItemResultsData) {
<div id="item-results-table">
if d.RunError != "" {
<div class="v-flash-error">{ d.RunError }</div>
} else if d.RunMsg != "" {
<div class="v-flash">{ d.RunMsg }</div>
}
<div class="v-card p-0 overflow-hidden">
<table class="v-table">
<thead>

View File

@@ -25,6 +25,10 @@ type ItemResultsData struct {
TotalPages int
Order string
HistoryChartJSON string
// RunMsg / RunError carry feedback from a "Run Now" poll. Both empty on a
// normal page load; PostRunItem sets exactly one.
RunMsg string
RunError string
}
type BadgeData struct {
@@ -74,7 +78,7 @@ func itemResultsBody(d ItemResultsData) 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/results.templ`, Line: 45, Col: 52}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 49, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -92,7 +96,7 @@ func itemResultsBody(d ItemResultsData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Category)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 47, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 51, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -115,7 +119,7 @@ func itemResultsBody(d ItemResultsData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(d.Item.BestPrice, "USD"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 52, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 56, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -133,7 +137,7 @@ func itemResultsBody(d ItemResultsData) templ.Component {
var templ_7745c5c3_Var5 templ.SafeURL
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(d.Item.BestPriceURL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 54, Col: 66}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 58, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -146,7 +150,7 @@ func itemResultsBody(d ItemResultsData) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.BestPriceStore)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 54, Col: 123}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 58, Col: 123}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -193,7 +197,7 @@ func itemResultsBody(d ItemResultsData) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(d.Badge.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 61, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 65, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -204,33 +208,33 @@ func itemResultsBody(d ItemResultsData) templ.Component {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<form class=\"mt-3\" hx-post=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<form class=\"mt-3 flex items-center gap-2 justify-end\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/run", d.Item.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 64, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 70, Col: 54}
}
_, 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, 17, "\" hx-swap=\"none\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" hx-target=\"#item-results-table\" 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_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 65, Col: 63}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 75, Col: 63}
}
_, 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, 18, "\"> <button class=\"v-btn\" type=\"submit\">Run Now</button></form></div></div><div class=\"v-card p-5 mb-6\"><h2 class=\"font-semibold mb-3\">Price History</h2>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\"> <input type=\"hidden\" name=\"from\" value=\"results\"> <span class=\"v-spinner htmx-indicator\"></span> <span class=\"v-muted text-sm htmx-indicator\">Running...</span> <button class=\"v-btn\" type=\"submit\">Run Now</button></form></div></div><div class=\"v-card p-5 mb-6\"><h2 class=\"font-semibold mb-3\">Price History</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -240,275 +244,370 @@ func itemResultsBody(d ItemResultsData) templ.Component {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<canvas id=\"price-chart\" height=\"120\"></canvas><script src=\"/static/vendor/chart.umd.min.js\"></script> <script id=\"price-data\" type=\"application/json\">{ d.HistoryChartJSON }</script> <script>\n\t\t\t\t(function(){\n\t\t\t\t\tvar data = JSON.parse(document.getElementById('price-data').textContent);\n\t\t\t\t\tvar ctx = document.getElementById('price-chart').getContext('2d');\n\t\t\t\t\tnew Chart(ctx, {\n\t\t\t\t\t\ttype: 'line',\n\t\t\t\t\t\tdata: {\n\t\t\t\t\t\t\tlabels: data.labels,\n\t\t\t\t\t\t\tdatasets: [{\n\t\t\t\t\t\t\t\tlabel: 'Best price',\n\t\t\t\t\t\t\t\tdata: data.points,\n\t\t\t\t\t\t\t\tborderColor: '#00e4a4',\n\t\t\t\t\t\t\t\tbackgroundColor: 'rgba(0,228,164,0.15)',\n\t\t\t\t\t\t\t\tpointBackgroundColor: '#e84040',\n\t\t\t\t\t\t\t\tpointRadius: 3,\n\t\t\t\t\t\t\t\ttension: 0.25,\n\t\t\t\t\t\t\t\tfill: true\n\t\t\t\t\t\t\t}]\n\t\t\t\t\t\t},\n\t\t\t\t\t\toptions: {\n\t\t\t\t\t\t\tscales: {\n\t\t\t\t\t\t\t\tx: { ticks: { color: '#a8c0f0' }, grid: { color: 'rgba(255,255,255,0.07)' } },\n\t\t\t\t\t\t\t\ty: { ticks: { color: '#a8c0f0' }, grid: { color: 'rgba(255,255,255,0.07)' } }\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tplugins: { legend: { labels: { color: '#ffffff' } } }\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t})();\n\t\t\t\t</script>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " <canvas id=\"price-chart\" height=\"120\" data-chart=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.HistoryChartJSON)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 92, Col: 73}
}
_, 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, 21, "\"></canvas><script src=\"/static/vendor/chart.umd.min.js\"></script> <script src=\"/static/js/price-chart.js\"></script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div><div class=\"v-card p-0 overflow-hidden\"><table class=\"v-table\"><thead><tr><th></th><th>Title</th><th><a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 templ.SafeURL
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "price"))))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 118, Col: 115}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
templ_7745c5c3_Err = ItemResultsTable(d).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">Price</a></th><th>Store</th><th><a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "found"))))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 122, Col: 115}
return nil
})
}
// ItemResultsTable is the results listing + pagination, plus optional "Run
// Now" feedback. It is both part of the initial page (via itemResultsBody)
// and the standalone response to POST /items/{id}/run from the results page,
// so the Run Now button targets #item-results-table with hx-swap="outerHTML".
func ItemResultsTable(d ItemResultsData) 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_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
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_Var13 := templ.GetChildren(ctx)
if templ_7745c5c3_Var13 == nil {
templ_7745c5c3_Var13 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div id=\"item-results-table\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">Found</a></th><th>Alert</th></tr></thead> <tbody>")
if d.RunError != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"v-flash-error\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.RunError)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 109, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if d.RunMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<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.RunMsg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 111, Col: 34}
}
_, 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, 28, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"v-card p-0 overflow-hidden\"><table class=\"v-table\"><thead><tr><th></th><th>Title</th><th><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 templ.SafeURL
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "price"))))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 120, Col: 115}
}
_, 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, 30, "\">Price</a></th><th>Store</th><th><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 templ.SafeURL
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "found"))))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 124, Col: 115}
}
_, 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, "\">Found</a></th><th>Alert</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, r := range d.Results {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<tr><td>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<tr><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if r.ImageURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<img src=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ImageURL)
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ImageURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 132, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 134, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" alt=\"\" class=\"w-10 h-10 object-cover rounded\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" alt=\"\" class=\"w-10 h-10 object-cover rounded\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</td><td>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if r.URL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 templ.SafeURL
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL))
var templ_7745c5c3_Var19 templ.SafeURL
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 137, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 139, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
_, 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, 29, "\" target=\"_blank\" rel=\"noopener\">")
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_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 137, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 139, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
_, 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, 30, "</a> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 139, Col: 18}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 141, Col: 18}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
_, 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, 31, " ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if r.MatchedQuery != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"v-muted text-xs\">via \"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<div class=\"v-muted text-xs\">via \"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery)
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 142, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 144, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
_, 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, 33, "\"</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</td>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 = []any{"font-mono", priceClass(r.Price, d.Item.TargetPrice)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...)
var templ_7745c5c3_Var23 = []any{"font-mono", priceClass(r.Price, d.Item.TargetPrice)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<td class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<td class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String())
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var23).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
_, 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, 36, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 145, Col: 105}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 147, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
_, 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, 37, "</td><td class=\"v-muted\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</td><td class=\"v-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 146, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 148, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
_, 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, 38, "</td><td class=\"v-muted text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</td><td class=\"v-muted text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt))
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 147, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 149, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
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, 47, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if r.Alerted {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<span class=\"v-pill v-pill-active\">sent</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<span class=\"v-pill v-pill-active\">sent</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</td></tr>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</tbody></table></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.TotalPages > 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"flex gap-2 justify-center my-4\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"flex gap-2 justify-center my-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for i := 1; i <= d.TotalPages; i++ {
var templ_7745c5c3_Var24 = []any{pageClass(i, d.Page_)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var24...)
var templ_7745c5c3_Var28 = []any{pageClass(i, d.Page_)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var28...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<a class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<a 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())
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var28).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 1, Col: 0}
}
_, 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, 45, "\" href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 templ.SafeURL
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results?page=%d&order=%s", d.Item.ID, i, d.Order)))
var templ_7745c5c3_Var30 templ.SafeURL
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results?page=%d&order=%s", d.Item.ID, i, d.Order)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 161, Col: 134}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 163, Col: 134}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", i))
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", i))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 161, Col: 159}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 163, Col: 159}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -555,9 +654,9 @@ func ItemResults(d ItemResultsData) 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, itemResultsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
@@ -584,238 +683,238 @@ func globalResultsBody(d GlobalResultsData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var29 := templ.GetChildren(ctx)
if templ_7745c5c3_Var29 == nil {
templ_7745c5c3_Var29 = templ.NopComponent
templ_7745c5c3_Var33 := templ.GetChildren(ctx)
if templ_7745c5c3_Var33 == nil {
templ_7745c5c3_Var33 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<div><h1 class=\"text-3xl font-semibold mb-6\">All Results</h1><form method=\"get\" action=\"/results\" class=\"flex items-end gap-3 mb-4\"><div><label class=\"v-label\">Item</label> <select class=\"v-select\" name=\"item_id\"><option value=\"0\">All</option> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<div><h1 class=\"text-3xl font-semibold mb-6\">All Results</h1><form method=\"get\" action=\"/results\" class=\"flex items-end gap-3 mb-4\"><div><label class=\"v-label\">Item</label> <select class=\"v-select\" name=\"item_id\"><option value=\"0\">All</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, it := range d.Items {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<option value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", it.ID))
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", it.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 204, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 206, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
_, 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, 52, "\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.ItemID == it.ID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(it.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 204, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</select></div><div><label class=\"v-label\">From</label> <input class=\"v-input\" type=\"date\" name=\"from\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.From)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 210, Col: 65}
}
_, 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, 57, "\"></div><div><label class=\"v-label\">To</label> <input class=\"v-input\" type=\"date\" name=\"to\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.To)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 214, Col: 61}
}
_, 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, 58, "\"></div><button class=\"v-btn\" type=\"submit\">Filter</button></form><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></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, r := range d.Results {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<tr><td><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 templ.SafeURL
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 226, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName)
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(it.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 226, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 206, Col: 90}
}
_, 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, 61, "</a></td><td>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</select></div><div><label class=\"v-label\">From</label> <input class=\"v-input\" type=\"date\" name=\"from\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.From)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 212, Col: 65}
}
_, 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, 65, "\"></div><div><label class=\"v-label\">To</label> <input class=\"v-input\" type=\"date\" name=\"to\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.To)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 216, Col: 61}
}
_, 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, 66, "\"></div><button class=\"v-btn\" type=\"submit\">Filter</button></form><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></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, r := range d.Results {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<tr><td><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 templ.SafeURL
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 228, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 228, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</a></td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if r.URL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 templ.SafeURL
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL))
var templ_7745c5c3_Var40 templ.SafeURL
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 229, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 231, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" target=\"_blank\" rel=\"noopener\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" target=\"_blank\" rel=\"noopener\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 229, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 231, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</a> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 231, Col: 18}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 233, Col: 18}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if r.MatchedQuery != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<div class=\"v-muted text-xs\">via \"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<div class=\"v-muted text-xs\">via \"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery)
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 234, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 236, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\"</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\"</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</td><td class=\"font-mono\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</td><td class=\"font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 237, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 239, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</td><td class=\"v-muted\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</td><td class=\"v-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 238, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 240, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</td><td class=\"v-muted text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</td><td class=\"v-muted text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt))
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 239, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 241, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</td><td>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if r.Alerted {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<span class=\"v-pill v-pill-active\">sent</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span class=\"v-pill v-pill-active\">sent</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</td></tr>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</tbody></table></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</tbody></table></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -839,9 +938,9 @@ func GlobalResults(d GlobalResultsData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var43 := templ.GetChildren(ctx)
if templ_7745c5c3_Var43 == nil {
templ_7745c5c3_Var43 = templ.NopComponent
templ_7745c5c3_Var47 := templ.GetChildren(ctx)
if templ_7745c5c3_Var47 == nil {
templ_7745c5c3_Var47 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Layout(d.Page, globalResultsBody(d)).Render(ctx, templ_7745c5c3_Buffer)

View File

@@ -8,15 +8,24 @@ import (
type SettingsData struct {
Page
Values map[string]string
IsAdmin bool
Users []models.User
TestNtfyOK string
TestApifyOK string
PasswordMsg string
PasswordError string
UserMsg string
UserError string
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
}
// EbayLimitReached reports whether eBay polling is currently halted because
// the daily call limit has been hit.
func (d SettingsData) EbayLimitReached() bool {
return d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit
}
templ settingsBody(d SettingsData) {
@@ -24,14 +33,37 @@ templ settingsBody(d SettingsData) {
<h1 class="text-3xl font-semibold">Settings</h1>
<section class="v-card p-6">
<h2 class="font-semibold mb-4">Apify and Ntfy</h2>
<h2 class="font-semibold mb-4">Apify, eBay and Ntfy</h2>
<form method="post" action="/settings" class="space-y-4">
@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"] }/>
</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"/>
</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"] }/>
</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={ d.Values["ebay_daily_call_limit"] } placeholder="5000 (blank uses config default)"/>
</div>
<div class="text-sm">
<span class="v-muted">eBay API calls today:</span>
if d.EbayDailyLimit > 0 {
<span class="font-mono">{ fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit) }</span>
} else {
<span class="font-mono">{ fmt.Sprintf("%d (uncapped)", d.EbayUsedToday) }</span>
}
if d.EbayLimitReached() {
<span class="v-flash-error inline-block ml-2">Limit reached. eBay polling halted until the next reset (midnight US Pacific).</span>
}
</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={ d.Values["ntfy_base_url"] }/>
</div>
@@ -58,6 +90,7 @@ templ settingsBody(d SettingsData) {
<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>
}
</form>
@@ -67,6 +100,9 @@ templ settingsBody(d SettingsData) {
if d.TestApifyOK != "" {
<div class="v-flash mt-3">{ d.TestApifyOK }</div>
}
if d.TestEbayOK != "" {
<div class="v-flash mt-3">{ d.TestEbayOK }</div>
}
</section>
<section class="v-card p-6">

View File

@@ -16,15 +16,24 @@ import (
type SettingsData struct {
Page
Values map[string]string
IsAdmin bool
Users []models.User
TestNtfyOK string
TestApifyOK string
PasswordMsg string
PasswordError string
UserMsg string
UserError string
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
}
// EbayLimitReached reports whether eBay polling is currently halted because
// the daily call limit has been hit.
func (d SettingsData) EbayLimitReached() bool {
return d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit
}
func settingsBody(d SettingsData) templ.Component {
@@ -48,7 +57,7 @@ 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 and Ntfy</h2><form method=\"post\" action=\"/settings\" class=\"space-y-4\">")
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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -63,177 +72,282 @@ func settingsBody(d SettingsData) templ.Component {
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: 32, Col: 108}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 41, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"></div><div><label class=\"v-label\">Ntfy Base URL</label> <input class=\"v-input\" name=\"ntfy_base_url\" value=\"")
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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_base_url"])
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: 36, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 45, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"></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, 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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_default_topic"])
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ebay_client_secret"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 40, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 49, Col: 118}
}
_, 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\">Ntfy Token</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ntfy_token\" value=\"")
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["ntfy_token"])
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: 44, Col: 102}
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=\"tk_... (leave blank if ntfy is unauthenticated)\"></div><div><label class=\"v-label\">Global Poll Interval (minutes)</label> <input class=\"v-input font-mono\" name=\"global_poll_interval_minutes\" type=\"number\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" placeholder=\"5000 (blank uses config default)\"></div><div class=\"text-sm\"><span class=\"v-muted\">eBay API calls today:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, 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: 48, Col: 136}
}
_, 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, 7, "\"></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_Var7 string
templ_7745c5c3_Var7, 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: 52, Col: 160}
}
_, 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, 8, "\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !d.IsAdmin {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"v-muted text-sm\">Read-only for non-admin users.</div>")
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, 10, "<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></div>")
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))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 60, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</form>")
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>")
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 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"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 68, Col: 82}
}
_, 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=\"")
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"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 72, Col: 92}
}
_, 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=\"")
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"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 76, Col: 102}
}
_, 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>")
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>")
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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.TestNtfyOK != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"v-flash mt-3\">")
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_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK)
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: 65, Col: 44}
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_Var8))
_, 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, 13, "</div>")
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, 14, "<div class=\"v-flash mt-3\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"v-flash mt-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK)
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 68, Col: 45}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 101, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</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, 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)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 104, Col: 44}
}
_, 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, 26, "</div>")
if templ_7745c5c3_Err != nil {
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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.PasswordError != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"v-flash-error\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"v-flash-error\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError)
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 75, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 111, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
_, 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, 18, "</div>")
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, 19, "<div class=\"v-flash\">")
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_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordMsg)
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: 78, Col: 40}
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_Var11))
_, 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, 20, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<form method=\"post\" action=\"/settings/password\" class=\"space-y-4\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<form method=\"post\" action=\"/settings/password\" class=\"space-y-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -241,173 +355,173 @@ func settingsBody(d SettingsData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<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, 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.IsAdmin {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Users</h2>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<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, 24, "<div class=\"v-flash-error\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<div class=\"v-flash-error\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError)
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 102, Col: 45}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 138, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
_, 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, 25, "</div>")
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, 26, "<div class=\"v-flash\">")
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_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg)
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: 105, Col: 37}
}
_, 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, 27, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<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, 29, "<tr><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 112, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</td><td class=\"v-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, 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: 113, Col: 44}
}
_, 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, 31, "</td><td class=\"v-muted text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, 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: 114, Col: 70}
}
_, 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, 32, "</td><td class=\"text-right\"><form class=\"inline\" method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 templ.SafeURL
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/reset-password", u.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 116, Col: 113}
}
_, 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, 33, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 117, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\"> <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_Var19 templ.SafeURL
templ_7745c5c3_Var19, 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: 121, Col: 105}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 141, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" onsubmit=\"return confirm('Remove user?')\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div>")
if templ_7745c5c3_Err != nil {
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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, u := range d.Users {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<tr><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 122, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 148, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
_, 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, 36, "\"> <button class=\"v-btn-ghost\" type=\"submit\">Remove</button></form></td></tr>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</td><td class=\"v-muted\">")
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))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 149, Col: 44}
}
_, 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\">")
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"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 150, Col: 70}
}
_, 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, 43, "</td><td class=\"text-right\"><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)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 152, Col: 113}
}
_, 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=\"")
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}
}
_, 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</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, 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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 131, Col: 63}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 167, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
_, 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, 38, "\"><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, 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -431,9 +545,9 @@ func Settings(d SettingsData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var22 := templ.GetChildren(ctx)
if templ_7745c5c3_Var22 == nil {
templ_7745c5c3_Var22 = templ.NopComponent
templ_7745c5c3_Var28 := templ.GetChildren(ctx)
if templ_7745c5c3_Var28 == nil {
templ_7745c5c3_Var28 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer)