Compare commits
4 Commits
cfa01bd4ef
...
d87536c879
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d87536c879 | ||
|
|
08ff1695e0 | ||
|
|
fd1682e11b | ||
|
|
1ae2c50b9a |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
43
Makefile
Normal 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
|
||||
52
README.md
52
README.md
@@ -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.
|
||||
|
||||
@@ -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
19
go.mod
@@ -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
33
go.sum
@@ -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=
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
227
internal/ebay/client.go
Normal 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
|
||||
}
|
||||
45
internal/ebay/ebay_test.go
Normal file
45
internal/ebay/ebay_test.go
Normal 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
94
internal/ebay/types.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/"
|
||||
}
|
||||
|
||||
@@ -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,45 +261,15 @@ 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)
|
||||
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,
|
||||
slog.Warn("preview plan failed",
|
||||
"provider", p.Provider(),
|
||||
"marketplace", p.Marketplace(),
|
||||
"query", p.Query(),
|
||||
"raw", len(raw),
|
||||
"decoded", len(decoded),
|
||||
"usable", usable,
|
||||
"err", err,
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
merged = append(merged, decoded...)
|
||||
if primarySource == "" {
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
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{
|
||||
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
|
||||
|
||||
5
main.go
5
main.go
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
6
static/css/input.css
Normal 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
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
35
static/js/price-chart.js
Normal 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
13
tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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?">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, r := range d.Results {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<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=\"")
|
||||
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.ResolveAttributeValue(r.ImageURL)
|
||||
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: 132, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 109, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
_, 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, "\" alt=\"\" class=\"w-10 h-10 object-cover rounded\">")
|
||||
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, "</td><td>")
|
||||
} 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
|
||||
}
|
||||
if r.URL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<a href=\"")
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.RunMsg)
|
||||
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))
|
||||
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: 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, 29, "\" target=\"_blank\" rel=\"noopener\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||
}
|
||||
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.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 137, Col: 82}
|
||||
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, "</a> ")
|
||||
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
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||
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: 139, Col: 18}
|
||||
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, " ")
|
||||
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, 32, "<tr><td>")
|
||||
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 \"")
|
||||
if r.ImageURL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery)
|
||||
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: 142, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 134, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
_, 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, 33, "\"</div>")
|
||||
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, 34, "</td>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</td><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...)
|
||||
if r.URL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<td class=\"")
|
||||
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: 139, Col: 39}
|
||||
}
|
||||
_, 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, 37, "\" target=\"_blank\" rel=\"noopener\">")
|
||||
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())
|
||||
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: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 139, Col: 82}
|
||||
}
|
||||
_, 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, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
|
||||
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: 145, Col: 105}
|
||||
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_Var21))
|
||||
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, 39, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if r.MatchedQuery != "" {
|
||||
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_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
|
||||
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: 146, Col: 37}
|
||||
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_Var22))
|
||||
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\">")
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 147, Col: 57}
|
||||
}
|
||||
_, 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, 39, "</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, 41, "\"</div>")
|
||||
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, 42, "</td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</tbody></table></div>")
|
||||
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
|
||||
}
|
||||
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, 43, "<td class=\"")
|
||||
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_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_Var24)
|
||||
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, 44, "\">")
|
||||
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())
|
||||
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: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 147, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||
_, 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, 45, "\" href=\"")
|
||||
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_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_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: 161, Col: 134}
|
||||
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_Var26))
|
||||
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, 46, "</td><td class=\"v-muted text-sm\">")
|
||||
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))
|
||||
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: 161, Col: 159}
|
||||
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_Var27))
|
||||
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, 47, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if r.Alerted {
|
||||
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, 48, "</div>")
|
||||
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, 49, "</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, 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_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, 52, "<a class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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_Var29)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: 163, Col: 134}
|
||||
}
|
||||
_, 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, 54, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: 163, Col: 159}
|
||||
}
|
||||
_, 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, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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, 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
|
||||
}
|
||||
if r.URL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<a href=\"")
|
||||
}
|
||||
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 templ.SafeURL
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL))
|
||||
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: 229, Col: 39}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 212, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
||||
_, 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, 63, "\" target=\"_blank\" rel=\"noopener\">")
|
||||
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.JoinStringErrs(r.Title)
|
||||
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: 229, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 216, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||
_, 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, 64, "</a> ")
|
||||
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
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||
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.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 231, Col: 18}
|
||||
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, 65, " ")
|
||||
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, 68, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery)
|
||||
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: 234, Col: 59}
|
||||
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, 67, "\"</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</a></td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</td><td class=\"font-mono\">")
|
||||
if r.URL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<a href=\"")
|
||||
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_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: 237, Col: 60}
|
||||
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_Var40))
|
||||
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, 71, "\" target=\"_blank\" rel=\"noopener\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
|
||||
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: 238, Col: 37}
|
||||
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_Var41))
|
||||
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, 72, "</a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt))
|
||||
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: 239, Col: 57}
|
||||
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_Var42))
|
||||
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, 73, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if r.MatchedQuery != "" {
|
||||
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_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: 236, Col: 59}
|
||||
}
|
||||
_, 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, 75, "\"</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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_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: 239, Col: 60}
|
||||
}
|
||||
_, 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, 77, "</td><td class=\"v-muted\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: 240, Col: 37}
|
||||
}
|
||||
_, 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, 78, "</td><td class=\"v-muted text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: 241, Col: 57}
|
||||
}
|
||||
_, 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, 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)
|
||||
|
||||
@@ -13,25 +13,57 @@ type SettingsData struct {
|
||||
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) {
|
||||
<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>
|
||||
<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">
|
||||
|
||||
@@ -21,12 +21,21 @@ type SettingsData struct {
|
||||
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 {
|
||||
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
|
||||
@@ -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
|
||||
}
|
||||
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.ResolveAttributeValue(d.Values["global_poll_interval_minutes"])
|
||||
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: 48, Col: 136}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 58, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
_, 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, 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>")
|
||||
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
|
||||
}
|
||||
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, 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.JoinStringErrs(d.TestNtfyOK)
|
||||
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: 65, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 68, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
_, 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>")
|
||||
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, 21, "<div class=\"v-flash mt-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 98, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.TestApifyOK != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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>")
|
||||
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, 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.ResolveAttributeValue(d.CSRFToken)
|
||||
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: 131, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 149, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
_, 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, 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, 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, 39, "</div>")
|
||||
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_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: 167, Col: 63}
|
||||
}
|
||||
_, 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, 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, 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)
|
||||
|
||||
Reference in New Issue
Block a user