Compare commits
7 Commits
cfa01bd4ef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea3577a45e | ||
|
|
0ec97afafb | ||
|
|
edb732ee1f | ||
|
|
d87536c879 | ||
|
|
08ff1695e0 | ||
|
|
fd1682e11b | ||
|
|
1ae2c50b9a |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -2,6 +2,9 @@
|
|||||||
veola-bin
|
veola-bin
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
|
# Fetched-on-demand tooling (Tailwind standalone CLI)
|
||||||
|
/bin/
|
||||||
|
|
||||||
# Local config (use config.toml.example as template)
|
# Local config (use config.toml.example as template)
|
||||||
config.toml
|
config.toml
|
||||||
|
|
||||||
@@ -16,3 +19,7 @@ config.toml
|
|||||||
*.swp
|
*.swp
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# Debug log output from `-debug` runs
|
||||||
|
veola-debug.log
|
||||||
|
*.log
|
||||||
|
|||||||
82
Makefile
Normal file
82
Makefile
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Veola build.
|
||||||
|
#
|
||||||
|
# Tool dependencies — the `*_VERSION` variables below are the single source of
|
||||||
|
# truth for every non-Go thing Veola pulls in at build time. To upgrade one,
|
||||||
|
# bump its version and run `make vendor` (vendored JS) or just `make css`
|
||||||
|
# (Tailwind) on a clean `bin/` to refetch.
|
||||||
|
#
|
||||||
|
# Go module dependencies live in go.mod and are bumped via `make update-deps`.
|
||||||
|
|
||||||
|
TAILWIND_VERSION := v3.4.17
|
||||||
|
HTMX_VERSION := 2.0.4
|
||||||
|
CHARTJS_VERSION := 4.4.6
|
||||||
|
TEMPL_VERSION := v0.3.1020
|
||||||
|
|
||||||
|
TAILWIND_BIN := bin/tailwindcss
|
||||||
|
# Override TAILWIND_URL for non-linux-x64 platforms — see README "Build-time
|
||||||
|
# tools" for the matching asset names on the Tailwind releases page.
|
||||||
|
TAILWIND_URL ?= https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64
|
||||||
|
|
||||||
|
HTMX_URL := https://unpkg.com/htmx.org@$(HTMX_VERSION)/dist/htmx.min.js
|
||||||
|
CHARTJS_URL := https://cdn.jsdelivr.net/npm/chart.js@$(CHARTJS_VERSION)/dist/chart.umd.min.js
|
||||||
|
|
||||||
|
TEMPL := $(shell go env GOPATH)/bin/templ
|
||||||
|
|
||||||
|
.PHONY: all generate css build run test clean tools vendor update-deps
|
||||||
|
|
||||||
|
all: build
|
||||||
|
|
||||||
|
$(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
|
||||||
|
|
||||||
|
# Install the pinned templ CLI into $(go env GOPATH)/bin. Pinning matters
|
||||||
|
# because two contributors on different templ versions can produce
|
||||||
|
# differently-formatted generated Go for the same .templ source.
|
||||||
|
tools:
|
||||||
|
go install github.com/a-h/templ/cmd/templ@$(TEMPL_VERSION)
|
||||||
|
|
||||||
|
# Re-fetch vendored third-party JS at the currently pinned versions,
|
||||||
|
# overwriting whatever is on disk. Also clears bin/tailwindcss so the next
|
||||||
|
# `make css` pulls the pinned Tailwind binary. Use after bumping any of
|
||||||
|
# HTMX_VERSION / CHARTJS_VERSION / TAILWIND_VERSION.
|
||||||
|
vendor:
|
||||||
|
curl -sL --fail $(HTMX_URL) -o static/vendor/htmx.min.js
|
||||||
|
curl -sL --fail $(CHARTJS_URL) -o static/vendor/chart.umd.min.js
|
||||||
|
rm -f $(TAILWIND_BIN)
|
||||||
|
$(MAKE) $(TAILWIND_BIN)
|
||||||
|
|
||||||
|
# Bump Go module dependencies to their newest compatible versions, then
|
||||||
|
# print upstream-release URLs for the non-Go tools so you can decide
|
||||||
|
# whether to bump those pins too. Does not modify the pinned versions.
|
||||||
|
update-deps:
|
||||||
|
go get -u ./...
|
||||||
|
go mod tidy
|
||||||
|
@echo
|
||||||
|
@echo "Pinned tool versions (current → upstream releases):"
|
||||||
|
@printf " Tailwind %s → https://github.com/tailwindlabs/tailwindcss/releases/latest\n" "$(TAILWIND_VERSION)"
|
||||||
|
@printf " htmx %s → https://github.com/bigskysoftware/htmx/releases/latest\n" "$(HTMX_VERSION)"
|
||||||
|
@printf " Chart.js %s → https://github.com/chartjs/Chart.js/releases/latest\n" "$(CHARTJS_VERSION)"
|
||||||
|
@printf " templ %s → https://github.com/a-h/templ/releases/latest\n" "$(TEMPL_VERSION)"
|
||||||
157
README.md
157
README.md
@@ -1,13 +1,14 @@
|
|||||||
# Veola
|
# 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.
|
Track. Watch. Notice.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Watch arbitrary items across multiple marketplaces with per-item search queries, target prices, and poll intervals
|
- 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
|
- Price-history chart and best-price badge once enough history accumulates
|
||||||
- Deal alerts pushed to ntfy when current price falls at or below target
|
- Deal alerts pushed to ntfy when current price falls at or below target
|
||||||
- Single-binary deploy, SQLite storage, no CGO
|
- Single-binary deploy, SQLite storage, no CGO
|
||||||
@@ -17,22 +18,81 @@ See [`veola-spec.md`](veola-spec.md) for the full specification.
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Go 1.22+ (developed against 1.25)
|
- Go 1.22+ (developed against 1.25)
|
||||||
- An [Apify](https://apify.com) account + API key
|
- `make`, `curl`, and a POSIX shell (for the Makefile)
|
||||||
- A reachable [ntfy](https://ntfy.sh) instance (self-hosted or ntfy.sh)
|
- A reachable [ntfy](https://ntfy.sh) instance (self-hosted or ntfy.sh)
|
||||||
|
- An [eBay developer](https://developer.ebay.com) keyset (App ID + Cert ID) — for eBay marketplaces
|
||||||
|
- An [Apify](https://apify.com) account + API key — for the non-eBay marketplaces
|
||||||
|
|
||||||
|
### Build-time tools
|
||||||
|
|
||||||
|
All non-Go tool versions are pinned in the Makefile at the top:
|
||||||
|
|
||||||
|
```make
|
||||||
|
TAILWIND_VERSION := v3.4.17
|
||||||
|
HTMX_VERSION := 2.0.4
|
||||||
|
CHARTJS_VERSION := 4.4.6
|
||||||
|
TEMPL_VERSION := v0.3.1020
|
||||||
|
```
|
||||||
|
|
||||||
|
Install `templ` yourself at the pinned version; Tailwind is downloaded for you on first `make css`. htmx and Chart.js are already vendored in `static/vendor/` at the pinned versions above and committed to the repo.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Install the pinned templ CLI.
|
||||||
|
make tools
|
||||||
|
|
||||||
|
# (or, equivalently)
|
||||||
|
go install github.com/a-h/templ/cmd/templ@v0.3.1020
|
||||||
|
|
||||||
|
# Confirm it's on PATH (typically $(go env GOPATH)/bin).
|
||||||
|
templ --version
|
||||||
|
```
|
||||||
|
|
||||||
|
You **do not need Node, npm, or Yarn**. Tailwind ships as a self-contained binary — the Makefile fetches the [standalone Tailwind CLI](https://github.com/tailwindlabs/tailwindcss/releases) into `./bin/tailwindcss` on first `make css`. The `bin/` directory is gitignored.
|
||||||
|
|
||||||
|
By default the Makefile downloads the **linux-x64** build. For other platforms, override `TAILWIND_URL` on the command line — pick the matching asset from the [Tailwind releases page](https://github.com/tailwindlabs/tailwindcss/releases/tag/v3.4.17):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# macOS Apple Silicon
|
||||||
|
make css TAILWIND_URL=https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-macos-arm64
|
||||||
|
|
||||||
|
# macOS Intel
|
||||||
|
make css TAILWIND_URL=https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-macos-x64
|
||||||
|
|
||||||
|
# linux-arm64
|
||||||
|
make css TAILWIND_URL=https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-linux-arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
Or drop a binary at `./bin/tailwindcss` yourself and the Makefile will use it.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
First time:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go build -o veola-bin .
|
go install github.com/a-h/templ/cmd/templ@latest # if not already
|
||||||
|
make build # fetches Tailwind, runs templ, compiles
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Subsequent builds:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs `templ generate`, compiles Tailwind utilities into `static/css/tailwind.css`, 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 ./...` |
|
||||||
|
| `make clean` | Remove `veola-bin` |
|
||||||
|
|
||||||
The binary is named `veola-bin` rather than `veola` because the module is also `veola` — `go build` cannot write a binary with the same name as the module dir.
|
The binary is named `veola-bin` rather than `veola` because the module is also `veola` — `go build` cannot write a binary with the same name as the module dir.
|
||||||
|
|
||||||
If you change any `.templ` files, regenerate first:
|
`static/css/tailwind.css` is committed, so a deploy box can `go build -o veola-bin .` without the Tailwind CLI or even `make`. Re-run `make css` whenever you change templates or `static/css/input.css`. The hand-written component layer in `static/css/app.css` is loaded separately and needs no rebuild.
|
||||||
|
|
||||||
```sh
|
|
||||||
~/go/bin/templ generate
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configure
|
## Configure
|
||||||
|
|
||||||
@@ -48,7 +108,12 @@ Both `session_secret` and `encryption_key` must be at least 32 bytes and differe
|
|||||||
openssl rand -hex 32
|
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
|
## Run
|
||||||
|
|
||||||
@@ -56,12 +121,21 @@ openssl rand -hex 32
|
|||||||
./veola-bin -config config.toml
|
./veola-bin -config config.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
CLI flags:
|
||||||
|
|
||||||
|
| Flag | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `-config <path>` | `config.toml` | Path to the TOML config file |
|
||||||
|
| `-debug` | off | Verbose `log/slog` at `LevelDebug`. Logs raw external API payloads (eBay / ZenMarket / etc.) — useful when diagnosing parse failures. Not for production. |
|
||||||
|
|
||||||
First-run flow:
|
First-run flow:
|
||||||
|
|
||||||
1. Visit `http://localhost:8080/`. With no users, you are redirected to `/setup`.
|
1. Visit `http://localhost:8080/`. With no users, you are redirected to `/setup`.
|
||||||
2. Create the admin account.
|
2. Create the admin account. The first user is always an admin.
|
||||||
3. Log in at `/login`.
|
3. Log in at `/login`.
|
||||||
4. Add items at `/items/new`. Optionally fill in your 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.
|
||||||
|
|
||||||
|
Account registration is admin-only — there is no public signup. Once at least one user exists, `/setup` returns 404. New users are created from the Settings page by an admin.
|
||||||
|
|
||||||
The scheduler starts with the server and polls each active item on its configured interval. The bottom-of-hour global poll runs every `scheduler.global_poll_interval_minutes`.
|
The scheduler starts with the server and polls each active item on its configured interval. The bottom-of-hour global poll runs every `scheduler.global_poll_interval_minutes`.
|
||||||
|
|
||||||
@@ -75,12 +149,15 @@ internal/
|
|||||||
db/ SQLite schema, migrations, store
|
db/ SQLite schema, migrations, store
|
||||||
models/ domain types
|
models/ domain types
|
||||||
apify/ Apify API client
|
apify/ Apify API client
|
||||||
|
ebay/ eBay Browse API client (OAuth2 + item search)
|
||||||
ntfy/ ntfy push client
|
ntfy/ ntfy push client
|
||||||
auth/ session + CSRF
|
auth/ session + CSRF
|
||||||
scheduler/ poll loop, alert/dedup/badge logic
|
scheduler/ poll loop, alert/dedup/badge logic
|
||||||
handlers/ HTTP handlers
|
handlers/ HTTP handlers
|
||||||
templates/ templ components
|
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
|
## Test
|
||||||
@@ -89,14 +166,66 @@ static/ CSS, vendored htmx
|
|||||||
go test ./...
|
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.
|
||||||
|
|
||||||
|
## Keeping dependencies current
|
||||||
|
|
||||||
|
Veola pulls from four sources, all version-pinned for reproducibility.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Bump Go module deps to their newest compatible versions, then prints
|
||||||
|
# upstream-release URLs for the four pinned tools so you can spot bumps.
|
||||||
|
make update-deps
|
||||||
|
|
||||||
|
# After bumping any of TAILWIND_VERSION / HTMX_VERSION / CHARTJS_VERSION
|
||||||
|
# in the Makefile, refetch the vendored assets at the new pins:
|
||||||
|
make vendor
|
||||||
|
|
||||||
|
# After bumping TEMPL_VERSION:
|
||||||
|
make tools
|
||||||
|
|
||||||
|
# Then rebuild and run the test suite:
|
||||||
|
make build && make test
|
||||||
|
```
|
||||||
|
|
||||||
|
The pinned tool versions live at the top of the Makefile. The vendored JS at `static/vendor/htmx.min.js` and `static/vendor/chart.umd.min.js` is committed and updated only by `make vendor`. Tailwind v3.4.17 is intentionally pinned — v4 is a breaking release that drops the `tailwind.config.js` format Veola uses.
|
||||||
|
|
||||||
|
For automated tracking, point [Dependabot](https://docs.github.com/en/code-security/dependabot) or [Renovate](https://docs.renovatebot.com/) at the repo. Both can watch `go.mod` natively; Renovate's custom-regex managers can also track the `*_VERSION` lines in the Makefile.
|
||||||
|
|
||||||
## Operate
|
## Operate
|
||||||
|
|
||||||
- The SQLite file lives at `server.db_path` (default `./veola.db`). Back this up — it holds your watched items, history, encrypted secrets, and user accounts.
|
- The SQLite file lives at `server.db_path` (default `./veola.db`). Back this up — it holds your watched items, history, encrypted secrets, and user accounts.
|
||||||
|
- `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.
|
- 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.
|
- 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.
|
||||||
|
- `GET /healthz` returns 200 `ok` with no auth — wire it up to your proxy/uptime probe.
|
||||||
|
|
||||||
|
A hardened systemd unit template lives at [`deploy/veola.service`](deploy/veola.service). It assumes:
|
||||||
|
|
||||||
|
- Binary at `/usr/local/bin/veola-bin`
|
||||||
|
- Config at `/etc/veola/config.toml`
|
||||||
|
- A `veola` system user with `/var/lib/veola` as its working / data directory (the only writable path under `ReadWritePaths`)
|
||||||
|
|
||||||
|
Install sketch:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo useradd --system --home /var/lib/veola --shell /usr/sbin/nologin veola
|
||||||
|
sudo install -d -o veola -g veola -m 0750 /var/lib/veola /etc/veola
|
||||||
|
sudo install -m 0755 veola-bin /usr/local/bin/veola-bin
|
||||||
|
sudo install -m 0640 -o veola -g veola config.toml /etc/veola/config.toml
|
||||||
|
sudo install -m 0644 deploy/veola.service /etc/systemd/system/veola.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now veola
|
||||||
|
```
|
||||||
|
|
||||||
## Aesthetic
|
## Aesthetic
|
||||||
|
|
||||||
Sega Genesis blue. Not dark mode, not light mode — blue mode. See the visual design section of `veola-spec.md` for the palette.
|
Sega Genesis blue. Not dark mode, not light mode — blue mode. See the visual design section of `veola-spec.md` for the palette.
|
||||||
|
|||||||
BIN
Veola.webp
BIN
Veola.webp
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB |
@@ -1,6 +1,11 @@
|
|||||||
[server]
|
[server]
|
||||||
port = 8080
|
port = 8080
|
||||||
db_path = "./veola.db"
|
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]
|
[security]
|
||||||
# Both must be at least 32 bytes and different from each other.
|
# 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
|
yahoo_auctions_jp_sold = "" # no known verified sold-listings actor for Yahoo JP
|
||||||
mercari_jp = "cloud9_ai/mercari-scraper"
|
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]
|
[ntfy]
|
||||||
base_url = "https://ntfy.yourdomain.com"
|
base_url = "https://ntfy.yourdomain.com"
|
||||||
default_topic = "veola"
|
default_topic = "veola"
|
||||||
|
|||||||
52
deploy/veola.service
Normal file
52
deploy/veola.service
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Veola price tracker
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
|
||||||
|
# --- Edit these for your host ---------------------------------------------
|
||||||
|
# User must be able to read config.toml and write WorkingDirectory (sqlite WAL).
|
||||||
|
User=veola
|
||||||
|
Group=veola
|
||||||
|
WorkingDirectory=/var/lib/veola
|
||||||
|
ExecStart=/usr/local/bin/veola-bin -config /etc/veola/config.toml
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
# SIGINT triggers the graceful-shutdown path in main.go (matches Ctrl-C).
|
||||||
|
KillSignal=SIGINT
|
||||||
|
TimeoutStopSec=45s
|
||||||
|
|
||||||
|
# Hardening. Veola only needs to read its config, write its sqlite db, and
|
||||||
|
# reach the network. Everything else can be locked down.
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectKernelLogs=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
ProtectClock=true
|
||||||
|
ProtectHostname=true
|
||||||
|
ProtectProc=invisible
|
||||||
|
RestrictNamespaces=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
LockPersonality=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
SystemCallFilter=~@privileged @resources
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
AmbientCapabilities=
|
||||||
|
# Allow writes only to the sqlite db directory.
|
||||||
|
ReadWritePaths=/var/lib/veola
|
||||||
|
UMask=0027
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
19
go.mod
19
go.mod
@@ -3,21 +3,24 @@ module veola
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
github.com/BurntSushi/toml v1.6.0
|
||||||
github.com/a-h/templ v0.3.1020 // indirect
|
github.com/a-h/templ v0.3.1020
|
||||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de // indirect
|
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de
|
||||||
github.com/alexedwards/scs/v2 v2.9.0 // indirect
|
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/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/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
modernc.org/libc v1.72.0 // indirect
|
modernc.org/libc v1.72.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // 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/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 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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=
|
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 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
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.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 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
||||||
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
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 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
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 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
|
||||||
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
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=
|
||||||
|
|||||||
@@ -2,10 +2,70 @@ package apify
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// jstLocation is the timezone ZenMarket reports Yahoo Auctions JP end times
|
||||||
|
// in when the value lacks an explicit offset. Falls back to UTC if the
|
||||||
|
// embedded tzdata lookup fails (main.go imports time/tzdata so in practice
|
||||||
|
// this always resolves).
|
||||||
|
var jstLocation = func() *time.Location {
|
||||||
|
loc, err := time.LoadLocation("Asia/Tokyo")
|
||||||
|
if err != nil {
|
||||||
|
return time.UTC
|
||||||
|
}
|
||||||
|
return loc
|
||||||
|
}()
|
||||||
|
|
||||||
|
// yahooEndingDateLayouts is the ordered list of layouts attempted when
|
||||||
|
// parsing ZenMarket's `ending_date`. Some entries use Local-as-JST (no
|
||||||
|
// offset in the string) — see parseYahooEndingDate for the routing.
|
||||||
|
var yahooEndingDateLayouts = []struct {
|
||||||
|
layout string
|
||||||
|
jst bool // true: parse as JST; false: parse with offset/zone from string
|
||||||
|
}{
|
||||||
|
{time.RFC3339, false},
|
||||||
|
{"2006-01-02T15:04:05", true},
|
||||||
|
{"2006-01-02 15:04:05", true},
|
||||||
|
{"2006/01/02 15:04:05", true},
|
||||||
|
{"2006/01/02 15:04", true},
|
||||||
|
{"2006-01-02 15:04", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseYahooEndingDate is a permissive parser for ZenMarket's ending_date
|
||||||
|
// field. The actor's output format has shifted over time and isn't documented;
|
||||||
|
// rather than wedging on one layout, try the known shapes in order and treat
|
||||||
|
// anything without an explicit zone as JST (Yahoo Auctions runs in Japan).
|
||||||
|
// Returns nil + logs a warning when nothing parses, so operators see the raw
|
||||||
|
// value and can extend this list.
|
||||||
|
func parseYahooEndingDate(s string) *time.Time {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, l := range yahooEndingDateLayouts {
|
||||||
|
var (
|
||||||
|
t time.Time
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if l.jst {
|
||||||
|
t, err = time.ParseInLocation(l.layout, s, jstLocation)
|
||||||
|
} else {
|
||||||
|
t, err = time.Parse(l.layout, s)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
slog.Debug("yahoo ending_date parsed",
|
||||||
|
"raw", s, "layout", l.layout, "jst_assumed", l.jst, "parsed", t.UTC().Format(time.RFC3339))
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Warn("yahoo ending_date: no layout matched", "raw", s)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ActiveListingInput is the input schema for `automation-lab/ebay-scraper`.
|
// ActiveListingInput is the input schema for `automation-lab/ebay-scraper`.
|
||||||
// The actor accepts keyword searches and standard filters; it targets
|
// The actor accepts keyword searches and standard filters; it targets
|
||||||
// ebay.com only (no per-marketplace routing in the actor itself), so
|
// ebay.com only (no per-marketplace routing in the actor itself), so
|
||||||
@@ -145,6 +205,10 @@ type UnifiedResult struct {
|
|||||||
// MatchedQuery records which alias from the item's query list produced
|
// MatchedQuery records which alias from the item's query list produced
|
||||||
// this row. Empty for URL-only items or rows from non-search sources.
|
// this row. Empty for URL-only items or rows from non-search sources.
|
||||||
MatchedQuery string
|
MatchedQuery string
|
||||||
|
// EndsAt is the auction end time, if known. Auction-format eBay listings
|
||||||
|
// and Yahoo Auctions JP listings populate this; fixed-price listings
|
||||||
|
// and most Apify scraper outputs leave it nil.
|
||||||
|
EndsAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode unmarshals a list of raw JSON items into UnifiedResult slices using
|
// Decode unmarshals a list of raw JSON items into UnifiedResult slices using
|
||||||
@@ -197,8 +261,10 @@ func Decode(items []json.RawMessage, source string) ([]UnifiedResult, error) {
|
|||||||
}
|
}
|
||||||
case SourceYahooJP:
|
case SourceYahooJP:
|
||||||
for _, raw := range items {
|
for _, raw := range items {
|
||||||
|
slog.Debug("yahoo raw item", "json", string(raw))
|
||||||
var r YahooAuctionsJPResult
|
var r YahooAuctionsJPResult
|
||||||
if err := json.Unmarshal(raw, &r); err != nil {
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
|
slog.Debug("yahoo decode failed", "err", err, "json", string(raw))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
img := ""
|
img := ""
|
||||||
@@ -213,6 +279,7 @@ func Decode(items []json.RawMessage, source string) ([]UnifiedResult, error) {
|
|||||||
Store: "yahoo-auctions-jp (via zenmarket)",
|
Store: "yahoo-auctions-jp (via zenmarket)",
|
||||||
ImageURL: img,
|
ImageURL: img,
|
||||||
Source: source,
|
Source: source,
|
||||||
|
EndsAt: parseYahooEndingDate(r.EndingDate),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
case SourceMercariJP:
|
case SourceMercariJP:
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ type Manager struct {
|
|||||||
hmacKey []byte
|
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 {
|
if len(sessionSecret) < 32 {
|
||||||
return nil, errors.New("session secret too short")
|
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.Path = "/"
|
||||||
sm.Cookie.SameSite = http.SameSiteLaxMode
|
sm.Cookie.SameSite = http.SameSiteLaxMode
|
||||||
sm.Cookie.Persist = true
|
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 := sha256.New()
|
||||||
mac.Write([]byte(sessionSecret))
|
mac.Write([]byte(sessionSecret))
|
||||||
@@ -70,6 +73,19 @@ func CheckPassword(hash, plain string) bool {
|
|||||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dummyHash is a valid bcrypt hash of a throwaway value. It exists only so a
|
||||||
|
// login attempt for a non-existent user can still run a real bcrypt
|
||||||
|
// comparison, equalizing response time and closing the user-enumeration
|
||||||
|
// timing oracle.
|
||||||
|
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("veola-timing-equalizer"), BcryptCost)
|
||||||
|
|
||||||
|
// EqualizeLoginTiming performs a bcrypt comparison against a throwaway hash so
|
||||||
|
// that a missing username costs roughly the same wall-clock time as a wrong
|
||||||
|
// password. Call it on the no-such-user branch of login.
|
||||||
|
func EqualizeLoginTiming() {
|
||||||
|
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte("veola-timing-equalizer-x"))
|
||||||
|
}
|
||||||
|
|
||||||
// LogIn writes the user id into the session and rotates the token.
|
// LogIn writes the user id into the session and rotates the token.
|
||||||
func (m *Manager) LogIn(ctx context.Context, userID int64) error {
|
func (m *Manager) LogIn(ctx context.Context, userID int64) error {
|
||||||
if err := m.Sessions.RenewToken(ctx); err != nil {
|
if err := m.Sessions.RenewToken(ctx); err != nil {
|
||||||
|
|||||||
@@ -13,13 +13,43 @@ type Config struct {
|
|||||||
Server ServerConfig `toml:"server"`
|
Server ServerConfig `toml:"server"`
|
||||||
Security SecurityConfig `toml:"security"`
|
Security SecurityConfig `toml:"security"`
|
||||||
Apify ApifyConfig `toml:"apify"`
|
Apify ApifyConfig `toml:"apify"`
|
||||||
|
Ebay EbayConfig `toml:"ebay"`
|
||||||
Ntfy NtfyConfig `toml:"ntfy"`
|
Ntfy NtfyConfig `toml:"ntfy"`
|
||||||
Scheduler SchedulerConfig `toml:"scheduler"`
|
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 {
|
type ServerConfig struct {
|
||||||
Port int `toml:"port"`
|
Port int `toml:"port"`
|
||||||
DBPath string `toml:"db_path"`
|
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 {
|
type SecurityConfig struct {
|
||||||
@@ -111,6 +141,9 @@ func (c *Config) validate() error {
|
|||||||
if c.Ntfy.DefaultTopic == "" {
|
if c.Ntfy.DefaultTopic == "" {
|
||||||
c.Ntfy.DefaultTopic = "veola"
|
c.Ntfy.DefaultTopic = "veola"
|
||||||
}
|
}
|
||||||
|
if c.Ebay.DailyCallLimit == 0 {
|
||||||
|
c.Ebay.DailyCallLimit = 5000
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,22 @@ func Open(path string) (*sql.DB, error) {
|
|||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := addColumnIfMissing(conn, "items", "condition", "TEXT"); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := addColumnIfMissing(conn, "items", "region", "TEXT"); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := addColumnIfMissing(conn, "items", "best_price_currency", "TEXT"); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := addColumnIfMissing(conn, "results", "ends_at", "DATETIME"); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -213,6 +213,60 @@ func (s *Store) SetSetting(ctx context.Context, key, value string) error {
|
|||||||
return err
|
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 ============
|
// ============ items ============
|
||||||
|
|
||||||
func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) {
|
func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) {
|
||||||
@@ -220,20 +274,20 @@ func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error)
|
|||||||
INSERT INTO items (
|
INSERT INTO items (
|
||||||
name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
|
name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
|
||||||
poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords,
|
poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords,
|
||||||
listing_type,
|
listing_type, condition, region,
|
||||||
actor_active, actor_sold, actor_price_compare, use_price_comparison,
|
actor_active, actor_sold, actor_price_compare, use_price_comparison,
|
||||||
active, best_price, best_price_store, best_price_url, best_price_image_url,
|
active, best_price, best_price_currency, best_price_store, best_price_url, best_price_image_url,
|
||||||
best_price_title, last_polled_at
|
best_price_title, last_polled_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category),
|
it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category),
|
||||||
nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority,
|
nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority,
|
||||||
it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock),
|
it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock),
|
||||||
nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)),
|
nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)),
|
||||||
nullStr(it.ListingType),
|
nullStr(it.ListingType), nullStr(it.Condition), nullStr(it.Region),
|
||||||
nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare),
|
nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare),
|
||||||
boolToInt(it.UsePriceComparison), boolToInt(it.Active),
|
boolToInt(it.UsePriceComparison), boolToInt(it.Active),
|
||||||
nullFloat(it.BestPrice), nullStr(it.BestPriceStore),
|
nullFloat(it.BestPrice), nullStr(it.BestPriceCurrency), nullStr(it.BestPriceStore),
|
||||||
nullStr(s.enc(it.BestPriceURL)), nullStr(s.enc(it.BestPriceImageURL)),
|
nullStr(s.enc(it.BestPriceURL)), nullStr(s.enc(it.BestPriceImageURL)),
|
||||||
nullStr(s.enc(it.BestPriceTitle)), nullTime(it.LastPolledAt),
|
nullStr(s.enc(it.BestPriceTitle)), nullTime(it.LastPolledAt),
|
||||||
)
|
)
|
||||||
@@ -256,7 +310,7 @@ func (s *Store) UpdateItem(ctx context.Context, it *models.Item) error {
|
|||||||
name = ?, search_query = ?, url = ?, category = ?, target_price = ?,
|
name = ?, search_query = ?, url = ?, category = ?, target_price = ?,
|
||||||
ntfy_topic = ?, ntfy_priority = ?, poll_interval_minutes = ?,
|
ntfy_topic = ?, ntfy_priority = ?, poll_interval_minutes = ?,
|
||||||
include_out_of_stock = ?, min_price = ?, exclude_keywords = ?,
|
include_out_of_stock = ?, min_price = ?, exclude_keywords = ?,
|
||||||
listing_type = ?,
|
listing_type = ?, condition = ?, region = ?,
|
||||||
actor_active = ?, actor_sold = ?, actor_price_compare = ?,
|
actor_active = ?, actor_sold = ?, actor_price_compare = ?,
|
||||||
use_price_comparison = ?, active = ?, updated_at = CURRENT_TIMESTAMP
|
use_price_comparison = ?, active = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
@@ -265,7 +319,7 @@ func (s *Store) UpdateItem(ctx context.Context, it *models.Item) error {
|
|||||||
nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority,
|
nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority,
|
||||||
it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock),
|
it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock),
|
||||||
nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)),
|
nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)),
|
||||||
nullStr(it.ListingType),
|
nullStr(it.ListingType), nullStr(it.Condition), nullStr(it.Region),
|
||||||
nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare),
|
nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare),
|
||||||
boolToInt(it.UsePriceComparison), boolToInt(it.Active),
|
boolToInt(it.UsePriceComparison), boolToInt(it.Active),
|
||||||
it.ID,
|
it.ID,
|
||||||
@@ -437,11 +491,12 @@ func (s *Store) ListCategories(ctx context.Context) ([]string, error) {
|
|||||||
// UpdateItemPollResult writes best-price fields, last_polled_at, last_poll_error.
|
// UpdateItemPollResult writes best-price fields, last_polled_at, last_poll_error.
|
||||||
func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models.Item, errMsg string) error {
|
func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models.Item, errMsg string) error {
|
||||||
var (
|
var (
|
||||||
bestPrice sql.NullFloat64
|
bestPrice sql.NullFloat64
|
||||||
bestStore, bestURL, bestImage, bestTitle, errField sql.NullString
|
bestCurrency, bestStore, bestURL, bestImage, bestTitle, errField sql.NullString
|
||||||
)
|
)
|
||||||
if best != nil {
|
if best != nil {
|
||||||
bestPrice = nullFloat(best.BestPrice)
|
bestPrice = nullFloat(best.BestPrice)
|
||||||
|
bestCurrency = nullStr(best.BestPriceCurrency)
|
||||||
bestStore = nullStr(best.BestPriceStore)
|
bestStore = nullStr(best.BestPriceStore)
|
||||||
bestURL = nullStr(s.enc(best.BestPriceURL))
|
bestURL = nullStr(s.enc(best.BestPriceURL))
|
||||||
bestImage = nullStr(s.enc(best.BestPriceImageURL))
|
bestImage = nullStr(s.enc(best.BestPriceImageURL))
|
||||||
@@ -452,20 +507,20 @@ func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models
|
|||||||
}
|
}
|
||||||
_, err := s.DB.ExecContext(ctx, `
|
_, err := s.DB.ExecContext(ctx, `
|
||||||
UPDATE items SET
|
UPDATE items SET
|
||||||
best_price = ?, best_price_store = ?, best_price_url = ?,
|
best_price = ?, best_price_currency = ?, best_price_store = ?, best_price_url = ?,
|
||||||
best_price_image_url = ?, best_price_title = ?,
|
best_price_image_url = ?, best_price_title = ?,
|
||||||
last_polled_at = CURRENT_TIMESTAMP, last_poll_error = ?
|
last_polled_at = CURRENT_TIMESTAMP, last_poll_error = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`, bestPrice, bestStore, bestURL, bestImage, bestTitle, errField, id)
|
`, bestPrice, bestCurrency, bestStore, bestURL, bestImage, bestTitle, errField, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemSelect = `
|
const itemSelect = `
|
||||||
SELECT id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
|
SELECT id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority,
|
||||||
poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords,
|
poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords,
|
||||||
listing_type,
|
listing_type, condition, region,
|
||||||
actor_active, actor_sold, actor_price_compare, use_price_comparison,
|
actor_active, actor_sold, actor_price_compare, use_price_comparison,
|
||||||
active, last_polled_at, last_poll_error, best_price, best_price_store,
|
active, last_polled_at, last_poll_error, best_price, best_price_currency, best_price_store,
|
||||||
best_price_url, best_price_image_url, best_price_title, created_at, updated_at
|
best_price_url, best_price_image_url, best_price_title, created_at, updated_at
|
||||||
FROM items
|
FROM items
|
||||||
`
|
`
|
||||||
@@ -478,10 +533,10 @@ func scanItem(r rowScanner) (*models.Item, error) {
|
|||||||
var (
|
var (
|
||||||
it models.Item
|
it models.Item
|
||||||
searchQuery, urlS, category, listingType sql.NullString
|
searchQuery, urlS, category, listingType sql.NullString
|
||||||
excludeKw sql.NullString
|
excludeKw, condition, region sql.NullString
|
||||||
actorA, actorS, actorP sql.NullString
|
actorA, actorS, actorP sql.NullString
|
||||||
ntfyTopic, lastPollErr sql.NullString
|
ntfyTopic, lastPollErr sql.NullString
|
||||||
bestStore, bestURL, bestImage, bestTitle sql.NullString
|
bestCurrency, bestStore, bestURL, bestImage, bestTitle sql.NullString
|
||||||
targetPrice, minPrice, bestPrice sql.NullFloat64
|
targetPrice, minPrice, bestPrice sql.NullFloat64
|
||||||
includeOOS, usePC, active int
|
includeOOS, usePC, active int
|
||||||
lastPolledAt sql.NullTime
|
lastPolledAt sql.NullTime
|
||||||
@@ -489,9 +544,9 @@ func scanItem(r rowScanner) (*models.Item, error) {
|
|||||||
if err := r.Scan(
|
if err := r.Scan(
|
||||||
&it.ID, &it.Name, &searchQuery, &urlS, &category, &targetPrice, &ntfyTopic, &it.NtfyPriority,
|
&it.ID, &it.Name, &searchQuery, &urlS, &category, &targetPrice, &ntfyTopic, &it.NtfyPriority,
|
||||||
&it.PollIntervalMinutes, &includeOOS, &minPrice, &excludeKw,
|
&it.PollIntervalMinutes, &includeOOS, &minPrice, &excludeKw,
|
||||||
&listingType,
|
&listingType, &condition, ®ion,
|
||||||
&actorA, &actorS, &actorP, &usePC,
|
&actorA, &actorS, &actorP, &usePC,
|
||||||
&active, &lastPolledAt, &lastPollErr, &bestPrice, &bestStore,
|
&active, &lastPolledAt, &lastPollErr, &bestPrice, &bestCurrency, &bestStore,
|
||||||
&bestURL, &bestImage, &bestTitle, &it.CreatedAt, &it.UpdatedAt,
|
&bestURL, &bestImage, &bestTitle, &it.CreatedAt, &it.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -502,11 +557,14 @@ func scanItem(r rowScanner) (*models.Item, error) {
|
|||||||
it.URL = urlS.String
|
it.URL = urlS.String
|
||||||
it.Category = category.String
|
it.Category = category.String
|
||||||
it.ListingType = listingType.String
|
it.ListingType = listingType.String
|
||||||
|
it.Condition = condition.String
|
||||||
|
it.Region = region.String
|
||||||
it.ActorActive = actorA.String
|
it.ActorActive = actorA.String
|
||||||
it.ActorSold = actorS.String
|
it.ActorSold = actorS.String
|
||||||
it.ActorPriceCompare = actorP.String
|
it.ActorPriceCompare = actorP.String
|
||||||
it.NtfyTopic = ntfyTopic.String
|
it.NtfyTopic = ntfyTopic.String
|
||||||
it.LastPollError = lastPollErr.String
|
it.LastPollError = lastPollErr.String
|
||||||
|
it.BestPriceCurrency = bestCurrency.String
|
||||||
it.BestPriceStore = bestStore.String
|
it.BestPriceStore = bestStore.String
|
||||||
it.BestPriceURL = bestURL.String
|
it.BestPriceURL = bestURL.String
|
||||||
it.BestPriceImageURL = bestImage.String
|
it.BestPriceImageURL = bestImage.String
|
||||||
@@ -535,13 +593,14 @@ func (s *Store) decryptItem(it *models.Item) *models.Item {
|
|||||||
|
|
||||||
func (s *Store) InsertResult(ctx context.Context, r *models.Result) (int64, error) {
|
func (s *Store) InsertResult(ctx context.Context, r *models.Result) (int64, error) {
|
||||||
res, err := s.DB.ExecContext(ctx, `
|
res, err := s.DB.ExecContext(ctx, `
|
||||||
INSERT INTO results (item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at)
|
INSERT INTO results (item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at, ends_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
|
||||||
`,
|
`,
|
||||||
r.ItemID, s.enc(r.Title), nullFloat(r.Price), r.Currency,
|
r.ItemID, s.enc(r.Title), nullFloat(r.Price), r.Currency,
|
||||||
nullStr(r.URL), nullStr(r.Source), s.enc(r.ImageURL),
|
nullStr(r.URL), nullStr(r.Source), s.enc(r.ImageURL),
|
||||||
nullStr(s.enc(r.MatchedQuery)),
|
nullStr(s.enc(r.MatchedQuery)),
|
||||||
boolToInt(r.Alerted),
|
boolToInt(r.Alerted),
|
||||||
|
nullTime(r.EndsAt),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -570,11 +629,28 @@ func (s *Store) MarkResultAlerted(ctx context.Context, id int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BackfillResultEndsAt sets ends_at on an existing result row that's currently
|
||||||
|
// missing one. Used during polling: when a known auction listing reappears in
|
||||||
|
// a poll result, we still want its end time recorded even though the row
|
||||||
|
// itself isn't being re-inserted (URL dedup).
|
||||||
|
func (s *Store) BackfillResultEndsAt(ctx context.Context, itemID int64, urlStr string, endsAt time.Time) error {
|
||||||
|
if urlStr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.DB.ExecContext(ctx,
|
||||||
|
`UPDATE results SET ends_at = ? WHERE item_id = ? AND url = ? AND ends_at IS NULL`,
|
||||||
|
endsAt, itemID, urlStr)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
type ResultsQuery struct {
|
type ResultsQuery struct {
|
||||||
ItemID int64 // 0 = all items
|
ItemID int64 // 0 = all items
|
||||||
Limit int
|
Limit int
|
||||||
Offset int
|
Offset int
|
||||||
Order string // "price_asc", "price_desc", "found_desc" (default), "found_asc"
|
Order string // "price_asc", "price_desc", "found_desc" (default), "found_asc"
|
||||||
|
// ExcludeEnded drops rows whose ends_at is in the past. Fixed-price
|
||||||
|
// listings (ends_at IS NULL) are kept regardless: they don't expire.
|
||||||
|
ExcludeEnded bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Result, error) {
|
func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Result, error) {
|
||||||
@@ -592,14 +668,22 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul
|
|||||||
limit = 20
|
limit = 20
|
||||||
}
|
}
|
||||||
args := []any{}
|
args := []any{}
|
||||||
where := ""
|
var conds []string
|
||||||
if q.ItemID != 0 {
|
if q.ItemID != 0 {
|
||||||
where = `WHERE item_id = ?`
|
conds = append(conds, `item_id = ?`)
|
||||||
args = append(args, q.ItemID)
|
args = append(args, q.ItemID)
|
||||||
}
|
}
|
||||||
|
if q.ExcludeEnded {
|
||||||
|
conds = append(conds, `(ends_at IS NULL OR ends_at > ?)`)
|
||||||
|
args = append(args, time.Now().UTC())
|
||||||
|
}
|
||||||
|
where := ""
|
||||||
|
if len(conds) > 0 {
|
||||||
|
where = `WHERE ` + strings.Join(conds, ` AND `)
|
||||||
|
}
|
||||||
args = append(args, limit, q.Offset)
|
args = append(args, limit, q.Offset)
|
||||||
rows, err := s.DB.QueryContext(ctx, fmt.Sprintf(`
|
rows, err := s.DB.QueryContext(ctx, fmt.Sprintf(`
|
||||||
SELECT id, item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at
|
SELECT id, item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at, ends_at
|
||||||
FROM results %s ORDER BY %s LIMIT ? OFFSET ?
|
FROM results %s ORDER BY %s LIMIT ? OFFSET ?
|
||||||
`, where, order), args...)
|
`, where, order), args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -613,8 +697,9 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul
|
|||||||
title, urlS, source, imageS, matchQ sql.NullString
|
title, urlS, source, imageS, matchQ sql.NullString
|
||||||
price sql.NullFloat64
|
price sql.NullFloat64
|
||||||
alerted int
|
alerted int
|
||||||
|
endsAt sql.NullTime
|
||||||
)
|
)
|
||||||
if err := rows.Scan(&r.ID, &r.ItemID, &title, &price, &r.Currency, &urlS, &source, &imageS, &matchQ, &alerted, &r.FoundAt); err != nil {
|
if err := rows.Scan(&r.ID, &r.ItemID, &title, &price, &r.Currency, &urlS, &source, &imageS, &matchQ, &alerted, &r.FoundAt, &endsAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
r.Title = s.dec(title.String)
|
r.Title = s.dec(title.String)
|
||||||
@@ -624,19 +709,76 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul
|
|||||||
r.MatchedQuery = s.dec(matchQ.String)
|
r.MatchedQuery = s.dec(matchQ.String)
|
||||||
r.Price = ptrFloat(price)
|
r.Price = ptrFloat(price)
|
||||||
r.Alerted = alerted != 0
|
r.Alerted = alerted != 0
|
||||||
|
r.EndsAt = ptrTime(endsAt)
|
||||||
out = append(out, r)
|
out = append(out, r)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CountResults(ctx context.Context, itemID int64) (int, error) {
|
// EndingSoon is a compact projection for the "ending soon" strip: the single
|
||||||
|
// nearest-to-end auction across the user's results, with enough context to
|
||||||
|
// render and link to it.
|
||||||
|
type EndingSoon struct {
|
||||||
|
ItemID int64
|
||||||
|
ItemName string
|
||||||
|
Title string
|
||||||
|
URL string
|
||||||
|
EndsAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextEndingResult returns the soonest-ending result whose ends_at lies in the
|
||||||
|
// window (now, now+within]. If itemID is 0 the search spans all items; nil is
|
||||||
|
// returned when no auction falls inside the window.
|
||||||
|
func (s *Store) NextEndingResult(ctx context.Context, itemID int64, within time.Duration) (*EndingSoon, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
cutoff := now.Add(within)
|
||||||
|
q := `SELECT r.item_id, r.title, r.url, r.ends_at, i.name
|
||||||
|
FROM results r JOIN items i ON r.item_id = i.id
|
||||||
|
WHERE r.ends_at IS NOT NULL AND r.ends_at > ? AND r.ends_at <= ?`
|
||||||
|
args := []any{now, cutoff}
|
||||||
|
if itemID != 0 {
|
||||||
|
q += ` AND r.item_id = ?`
|
||||||
|
args = append(args, itemID)
|
||||||
|
}
|
||||||
|
q += ` ORDER BY r.ends_at ASC LIMIT 1`
|
||||||
|
row := s.DB.QueryRowContext(ctx, q, args...)
|
||||||
|
var (
|
||||||
|
e EndingSoon
|
||||||
|
title sql.NullString
|
||||||
|
urlS sql.NullString
|
||||||
|
endsAt time.Time
|
||||||
|
)
|
||||||
|
if err := row.Scan(&e.ItemID, &title, &urlS, &endsAt, &e.ItemName); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.Title = s.dec(title.String)
|
||||||
|
e.URL = urlS.String
|
||||||
|
e.EndsAt = endsAt
|
||||||
|
return &e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountResults returns the row count matching the same filters ListResults
|
||||||
|
// applies. Pagination relies on this matching the visible list, so it must
|
||||||
|
// honor ExcludeEnded too.
|
||||||
|
func (s *Store) CountResults(ctx context.Context, itemID int64, excludeEnded bool) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
q := `SELECT COUNT(*) FROM results`
|
q := `SELECT COUNT(*) FROM results`
|
||||||
args := []any{}
|
args := []any{}
|
||||||
|
var conds []string
|
||||||
if itemID != 0 {
|
if itemID != 0 {
|
||||||
q += ` WHERE item_id = ?`
|
conds = append(conds, `item_id = ?`)
|
||||||
args = append(args, itemID)
|
args = append(args, itemID)
|
||||||
}
|
}
|
||||||
|
if excludeEnded {
|
||||||
|
conds = append(conds, `(ends_at IS NULL OR ends_at > ?)`)
|
||||||
|
args = append(args, time.Now().UTC())
|
||||||
|
}
|
||||||
|
if len(conds) > 0 {
|
||||||
|
q += ` WHERE ` + strings.Join(conds, ` AND `)
|
||||||
|
}
|
||||||
err := s.DB.QueryRowContext(ctx, q, args...).Scan(&n)
|
err := s.DB.QueryRowContext(ctx, q, args...).Scan(&n)
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
@@ -656,6 +798,49 @@ func (s *Store) InsertPricePoint(ctx context.Context, p *models.PricePoint) erro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadRecentPriceHistory bulk-loads the last `perItem` price points (oldest →
|
||||||
|
// newest) for each item id. Returns a map keyed by item id; items with no
|
||||||
|
// history are absent from the map. Used by the items list page to render a
|
||||||
|
// sparkline per row without N+1 queries.
|
||||||
|
func (s *Store) LoadRecentPriceHistory(ctx context.Context, ids []int64, perItem int) (map[int64][]models.PricePoint, error) {
|
||||||
|
out := make(map[int64][]models.PricePoint, len(ids))
|
||||||
|
if len(ids) == 0 || perItem <= 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
placeholders := make([]string, len(ids))
|
||||||
|
args := make([]any, 0, len(ids)+1)
|
||||||
|
for i, id := range ids {
|
||||||
|
placeholders[i] = "?"
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
args = append(args, perItem)
|
||||||
|
q := `
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT item_id, price, store, polled_at,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY polled_at DESC) AS rn
|
||||||
|
FROM price_history
|
||||||
|
WHERE item_id IN (` + strings.Join(placeholders, ",") + `)
|
||||||
|
)
|
||||||
|
SELECT item_id, price, store, polled_at FROM ranked WHERE rn <= ?
|
||||||
|
ORDER BY item_id, polled_at ASC
|
||||||
|
`
|
||||||
|
rows, err := s.DB.QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var p models.PricePoint
|
||||||
|
var store sql.NullString
|
||||||
|
if err := rows.Scan(&p.ItemID, &p.Price, &store, &p.PolledAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Store = s.dec(store.String)
|
||||||
|
out[p.ItemID] = append(out[p.ItemID], p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) ListPriceHistory(ctx context.Context, itemID int64) ([]models.PricePoint, error) {
|
func (s *Store) ListPriceHistory(ctx context.Context, itemID int64) ([]models.PricePoint, error) {
|
||||||
rows, err := s.DB.QueryContext(ctx,
|
rows, err := s.DB.QueryContext(ctx,
|
||||||
`SELECT id, item_id, price, store, polled_at FROM price_history WHERE item_id = ? ORDER BY polled_at ASC`,
|
`SELECT id, item_id, price, store, polled_at FROM price_history WHERE item_id = ? ORDER BY polled_at ASC`,
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
min_price REAL,
|
min_price REAL,
|
||||||
exclude_keywords TEXT,
|
exclude_keywords TEXT,
|
||||||
listing_type TEXT,
|
listing_type TEXT,
|
||||||
|
condition TEXT,
|
||||||
|
region TEXT,
|
||||||
actor_active TEXT,
|
actor_active TEXT,
|
||||||
actor_sold TEXT,
|
actor_sold TEXT,
|
||||||
actor_price_compare TEXT,
|
actor_price_compare TEXT,
|
||||||
@@ -31,6 +33,7 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
last_polled_at DATETIME,
|
last_polled_at DATETIME,
|
||||||
last_poll_error TEXT,
|
last_poll_error TEXT,
|
||||||
best_price REAL,
|
best_price REAL,
|
||||||
|
best_price_currency TEXT,
|
||||||
best_price_store TEXT,
|
best_price_store TEXT,
|
||||||
best_price_url TEXT,
|
best_price_url TEXT,
|
||||||
best_price_image_url TEXT,
|
best_price_image_url TEXT,
|
||||||
@@ -61,7 +64,8 @@ CREATE TABLE IF NOT EXISTS results (
|
|||||||
image_url TEXT,
|
image_url TEXT,
|
||||||
matched_query TEXT,
|
matched_query TEXT,
|
||||||
alerted INTEGER DEFAULT 0,
|
alerted INTEGER DEFAULT 0,
|
||||||
found_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
found_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ends_at DATETIME
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_results_item ON results(item_id, found_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_results_item ON results(item_id, found_at DESC);
|
||||||
@@ -90,6 +94,15 @@ INSERT OR IGNORE INTO settings (key, value) VALUES
|
|||||||
('global_poll_interval_minutes', '60'),
|
('global_poll_interval_minutes', '60'),
|
||||||
('match_confidence_threshold', '0.6');
|
('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 (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
data BLOB NOT NULL,
|
data BLOB NOT NULL,
|
||||||
|
|||||||
248
internal/ebay/client.go
Normal file
248
internal/ebay/client.go
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
// 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"`
|
||||||
|
// itemEndDate is present only on auction-format listings.
|
||||||
|
ItemEndDate string `json:"itemEndDate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
// The Browse API "filter" parameter takes a comma-separated list of
|
||||||
|
// filter clauses; assemble whichever ones the caller requested.
|
||||||
|
var filters []string
|
||||||
|
if f := buyingOptionsFilter(p.ListingType); f != "" {
|
||||||
|
filters = append(filters, f)
|
||||||
|
}
|
||||||
|
if f := conditionIDsFilter(p.Condition); f != "" {
|
||||||
|
filters = append(filters, f)
|
||||||
|
}
|
||||||
|
if f := itemLocationFilter(p.Region); f != "" {
|
||||||
|
filters = append(filters, f)
|
||||||
|
}
|
||||||
|
if len(filters) > 0 {
|
||||||
|
q.Set("filter", strings.Join(filters, ","))
|
||||||
|
}
|
||||||
|
reqURL := c.ends.browse + "/item_summary/search?" + q.Encode()
|
||||||
|
|
||||||
|
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 + ")"
|
||||||
|
}
|
||||||
|
var endsAt *time.Time
|
||||||
|
if s.ItemEndDate != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, s.ItemEndDate); err == nil {
|
||||||
|
endsAt = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, Listing{
|
||||||
|
Title: s.Title,
|
||||||
|
Price: price,
|
||||||
|
Currency: s.Price.Currency,
|
||||||
|
URL: s.ItemWebURL,
|
||||||
|
Store: store,
|
||||||
|
ImageURL: img,
|
||||||
|
EndsAt: endsAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
77
internal/ebay/ebay_test.go
Normal file
77
internal/ebay/ebay_test.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConditionIDsFilter(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"": "",
|
||||||
|
"anything": "",
|
||||||
|
"new": "conditionIds:{1000|1500}",
|
||||||
|
"NEW": "conditionIds:{1000|1500}",
|
||||||
|
" used ": "conditionIds:{3000}",
|
||||||
|
"refurbished": "conditionIds:{2000|2010|2020|2030|2500}",
|
||||||
|
"parts": "conditionIds:{7000}",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := conditionIDsFilter(in); got != want {
|
||||||
|
t.Errorf("conditionIDsFilter(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItemLocationFilter(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"": "",
|
||||||
|
" ": "",
|
||||||
|
"us": "itemLocationCountry:US",
|
||||||
|
"GB": "itemLocationCountry:GB",
|
||||||
|
" jp ": "itemLocationCountry:JP",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := itemLocationFilter(in); got != want {
|
||||||
|
t.Errorf("itemLocationFilter(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
internal/ebay/types.go
Normal file
140
internal/ebay/types.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package ebay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchParams is the input to a single Browse API item_summary/search call.
|
||||||
|
// It is provider-specific and is carried as the opaque input payload of a
|
||||||
|
// 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
|
||||||
|
// Condition is Veola's condition vocabulary ("new", "used",
|
||||||
|
// "refurbished", "parts"); it is mapped to a conditionIds filter. Empty
|
||||||
|
// means no condition filter.
|
||||||
|
Condition string
|
||||||
|
// Region is an ISO 3166-1 alpha-2 country code constraining item
|
||||||
|
// location (mapped to the itemLocationCountry filter). Empty means no
|
||||||
|
// location filter.
|
||||||
|
Region string
|
||||||
|
// Limit caps the number of results requested (Browse API max is 200).
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// EndsAt is the auction end time as reported by the Browse API
|
||||||
|
// (itemEndDate). Nil for fixed-price ("Buy It Now") listings, which
|
||||||
|
// don't have one.
|
||||||
|
EndsAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarketplaceID maps a Veola marketplace string (e.g. "ebay.com",
|
||||||
|
// "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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// conditionIDsFilter maps Veola's condition vocabulary to a Browse API
|
||||||
|
// "conditionIds" filter clause. Each Veola value expands to the set of eBay
|
||||||
|
// condition IDs that belong to it (e.g. "new" covers both brand-new and
|
||||||
|
// new-other). An empty or unknown value yields no filter.
|
||||||
|
func conditionIDsFilter(condition string) string {
|
||||||
|
var ids string
|
||||||
|
switch strings.ToLower(strings.TrimSpace(condition)) {
|
||||||
|
case "new":
|
||||||
|
ids = "1000|1500"
|
||||||
|
case "used":
|
||||||
|
ids = "3000"
|
||||||
|
case "refurbished":
|
||||||
|
ids = "2000|2010|2020|2030|2500"
|
||||||
|
case "parts":
|
||||||
|
ids = "7000"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "conditionIds:{" + ids + "}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemLocationFilter maps an ISO 3166-1 alpha-2 country code to a Browse API
|
||||||
|
// "itemLocationCountry" filter clause. An empty value yields no filter.
|
||||||
|
func itemLocationFilter(region string) string {
|
||||||
|
r := strings.ToUpper(strings.TrimSpace(region))
|
||||||
|
if r == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "itemLocationCountry:" + r
|
||||||
|
}
|
||||||
@@ -27,6 +27,11 @@ func (a *App) PostLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||||
password := r.PostFormValue("password")
|
password := r.PostFormValue("password")
|
||||||
u, err := a.Store.GetUserByUsername(r.Context(), username)
|
u, err := a.Store.GetUserByUsername(r.Context(), username)
|
||||||
|
if err != nil || u == nil {
|
||||||
|
// Run a bcrypt comparison anyway so a missing username takes the
|
||||||
|
// same time as a wrong password (no user-enumeration oracle).
|
||||||
|
auth.EqualizeLoginTiming()
|
||||||
|
}
|
||||||
if err != nil || u == nil || !auth.CheckPassword(u.PasswordHash, password) {
|
if err != nil || u == nil || !auth.CheckPassword(u.PasswordHash, password) {
|
||||||
render(w, r, templates.Login(templates.LoginData{
|
render(w, r, templates.Login(templates.LoginData{
|
||||||
Page: a.page(r, "Sign in", ""),
|
Page: a.page(r, "Sign in", ""),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
func (a *App) GetDashboard(w http.ResponseWriter, r *http.Request) {
|
func (a *App) GetDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
d, err := a.dashboardData(r)
|
d, err := a.dashboardData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
render(w, r, templates.Dashboard(d))
|
render(w, r, templates.Dashboard(d))
|
||||||
@@ -21,13 +21,14 @@ func (a *App) GetDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (a *App) GetDashboardRefresh(w http.ResponseWriter, r *http.Request) {
|
func (a *App) GetDashboardRefresh(w http.ResponseWriter, r *http.Request) {
|
||||||
d, err := a.dashboardData(r)
|
d, err := a.dashboardData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Render only the inner block by reusing the full body component; the
|
// Render ONLY the inner body. The hx-swap="outerHTML" on DashboardBody's
|
||||||
// outer hx-swap="outerHTML" replaces the same wrapper. The full Dashboard
|
// root div replaces it with this fresh copy. Rendering templates.Dashboard
|
||||||
// template is overkill but keeps a single source of truth.
|
// here would return the whole Layout — sidebar included — nested inside
|
||||||
render(w, r, templates.Dashboard(d))
|
// 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) {
|
func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
|
||||||
@@ -35,7 +36,7 @@ func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return templates.DashboardData{}, err
|
return templates.DashboardData{}, err
|
||||||
}
|
}
|
||||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{Limit: 20})
|
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{Limit: 20, ExcludeEnded: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return templates.DashboardData{}, err
|
return templates.DashboardData{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
"veola/internal/apify"
|
"veola/internal/apify"
|
||||||
"veola/internal/auth"
|
"veola/internal/auth"
|
||||||
@@ -43,9 +45,27 @@ func New(cfg *config.Config, store *db.Store, am *auth.Manager, ap *apify.Client
|
|||||||
// Routes returns the chi router with everything wired up.
|
// Routes returns the chi router with everything wired up.
|
||||||
func (a *App) Routes() http.Handler {
|
func (a *App) Routes() http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
fs := http.FileServer(http.Dir("./static"))
|
// 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)
|
||||||
|
|
||||||
|
// noListFS denies directory requests, so http.FileServer can't render
|
||||||
|
// an index listing of static/ if an index.html is ever absent.
|
||||||
|
fs := http.FileServer(noListFS{http.Dir("./static")})
|
||||||
r.Handle("/static/*", http.StripPrefix("/static/", fs))
|
r.Handle("/static/*", http.StripPrefix("/static/", fs))
|
||||||
|
|
||||||
|
// Health check for reverse-proxy/uptime probes. No session, no setup
|
||||||
|
// gate, no auth — just a 200 to confirm the process is serving.
|
||||||
|
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
// All other routes pass through session loading + setup gate.
|
// All other routes pass through session loading + setup gate.
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(a.Auth.Sessions.LoadAndSave)
|
r.Use(a.Auth.Sessions.LoadAndSave)
|
||||||
@@ -85,6 +105,7 @@ func (a *App) Routes() http.Handler {
|
|||||||
r.With(a.Auth.CSRFProtect).Post("/settings/password", a.PostPasswordChange)
|
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-ntfy", a.PostTestNtfy)
|
||||||
r.With(a.Auth.CSRFProtect).Post("/settings/test-apify", a.PostTestApify)
|
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", 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}/delete", a.PostDeleteUser)
|
||||||
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/reset-password", a.PostResetPassword)
|
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/reset-password", a.PostResetPassword)
|
||||||
@@ -114,6 +135,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 {
|
func isStaticPath(p string) bool {
|
||||||
return len(p) >= 8 && p[:8] == "/static/"
|
return len(p) >= 8 && p[:8] == "/static/"
|
||||||
}
|
}
|
||||||
@@ -127,6 +180,34 @@ func (a *App) page(r *http.Request, title, active string) templates.Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noListFS wraps an http.FileSystem and refuses to open directories, which
|
||||||
|
// stops http.FileServer from emitting an auto-generated directory listing.
|
||||||
|
type noListFS struct{ fs http.FileSystem }
|
||||||
|
|
||||||
|
func (n noListFS) Open(name string) (http.File, error) {
|
||||||
|
f, err := n.fs.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
f.Close()
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverError logs the underlying error and returns a generic 500 to the
|
||||||
|
// client, so internal details (DB errors, file paths) never reach the browser.
|
||||||
|
func (a *App) serverError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
slog.Error("handler error", "path", r.URL.Path, "err", err)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := c.Render(r.Context(), w); err != nil {
|
if err := c.Render(r.Context(), w); err != nil {
|
||||||
|
|||||||
115
internal/handlers/handlers_test.go
Normal file
115
internal/handlers/handlers_test.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"veola/internal/apify"
|
||||||
|
"veola/internal/auth"
|
||||||
|
"veola/internal/config"
|
||||||
|
"veola/internal/crypto"
|
||||||
|
"veola/internal/db"
|
||||||
|
"veola/internal/models"
|
||||||
|
"veola/internal/ntfy"
|
||||||
|
"veola/internal/scheduler"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestApp builds an App backed by a fresh sqlite db in t.TempDir(). The
|
||||||
|
// scheduler, apify, and ntfy clients are wired but unused by the routes we
|
||||||
|
// hit here. The returned http.Handler is App.Routes().
|
||||||
|
func newTestApp(t *testing.T) (*App, http.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
sqlDB, err := db.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("db.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { sqlDB.Close() })
|
||||||
|
|
||||||
|
key, err := crypto.DeriveKey([]byte("test-encryption-key-32-bytes-min-aaaaaa"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeriveKey: %v", err)
|
||||||
|
}
|
||||||
|
store := db.NewStore(sqlDB, key)
|
||||||
|
|
||||||
|
am, err := auth.NewManager(sqlDB, store, strings.Repeat("a", 32), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("auth.NewManager: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{}
|
||||||
|
ap := apify.New("")
|
||||||
|
nt := ntfy.New("")
|
||||||
|
sc := scheduler.New(cfg, store, ap, nt)
|
||||||
|
|
||||||
|
app := New(cfg, store, am, ap, nt, sc)
|
||||||
|
return app, app.Routes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthz(t *testing.T) {
|
||||||
|
_, h := newTestApp(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Body.String(); got != "ok" {
|
||||||
|
t.Fatalf("body = %q, want %q", got, "ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupGateRedirectsWhenNoUsers(t *testing.T) {
|
||||||
|
_, h := newTestApp(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if loc := rec.Header().Get("Location"); loc != "/setup" {
|
||||||
|
t.Fatalf("Location = %q, want /setup", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAuthRedirectsToLogin(t *testing.T) {
|
||||||
|
app, h := newTestApp(t)
|
||||||
|
hash, err := auth.HashPassword("a-long-enough-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := app.Store.CreateUser(context.Background(), "admin", hash, models.RoleAdmin); err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if loc := rec.Header().Get("Location"); loc != "/login" {
|
||||||
|
t.Fatalf("Location = %q, want /login", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginPageRenders(t *testing.T) {
|
||||||
|
app, h := newTestApp(t)
|
||||||
|
hash, _ := auth.HashPassword("a-long-enough-password")
|
||||||
|
if _, err := app.Store.CreateUser(context.Background(), "admin", hash, models.RoleAdmin); err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "<form") {
|
||||||
|
t.Fatalf("body missing <form>")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,13 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"veola/internal/apify"
|
"veola/internal/apify"
|
||||||
"veola/internal/models"
|
"veola/internal/models"
|
||||||
@@ -30,11 +30,22 @@ func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
cats, _ := a.Store.ListCategories(r.Context())
|
cats, _ := a.Store.ListCategories(r.Context())
|
||||||
|
|
||||||
|
// Bulk-load recent price history so each row can render a sparkline
|
||||||
|
// without N+1 queries. 20 points is enough for a meaningful trend line
|
||||||
|
// at 80px wide and stays cheap on the largest realistic watchlists.
|
||||||
|
ids := make([]int64, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
ids = append(ids, it.ID)
|
||||||
|
}
|
||||||
|
history, _ := a.Store.LoadRecentPriceHistory(r.Context(), ids, 20)
|
||||||
|
|
||||||
render(w, r, templates.Items(templates.ItemsData{
|
render(w, r, templates.Items(templates.ItemsData{
|
||||||
Page: a.page(r, "Items", "items"),
|
Page: a.page(r, "Items", "items"),
|
||||||
Items: items,
|
Items: items,
|
||||||
Categories: cats,
|
Categories: cats,
|
||||||
SelectedCategory: cat,
|
SelectedCategory: cat,
|
||||||
|
PriceHistory: history,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +104,8 @@ func parseItemForm(r *http.Request) (models.Item, []string) {
|
|||||||
}
|
}
|
||||||
it.Marketplaces = collectMarketplaces(r.PostForm["marketplace"], r.PostFormValue("marketplace_custom"))
|
it.Marketplaces = collectMarketplaces(r.PostForm["marketplace"], r.PostFormValue("marketplace_custom"))
|
||||||
it.ListingType = strings.TrimSpace(r.PostFormValue("listing_type"))
|
it.ListingType = strings.TrimSpace(r.PostFormValue("listing_type"))
|
||||||
|
it.Condition = strings.TrimSpace(r.PostFormValue("condition"))
|
||||||
|
it.Region = strings.ToUpper(strings.TrimSpace(r.PostFormValue("region")))
|
||||||
it.ActorActive = strings.TrimSpace(r.PostFormValue("actor_active"))
|
it.ActorActive = strings.TrimSpace(r.PostFormValue("actor_active"))
|
||||||
it.ActorSold = strings.TrimSpace(r.PostFormValue("actor_sold"))
|
it.ActorSold = strings.TrimSpace(r.PostFormValue("actor_sold"))
|
||||||
it.ActorPriceCompare = strings.TrimSpace(r.PostFormValue("actor_price_compare"))
|
it.ActorPriceCompare = strings.TrimSpace(r.PostFormValue("actor_price_compare"))
|
||||||
@@ -253,6 +266,8 @@ func (a *App) runPreview(ctx context.Context, it models.Item) ([]apify.UnifiedRe
|
|||||||
Marketplace: previewMarket,
|
Marketplace: previewMarket,
|
||||||
ListingType: it.ListingType,
|
ListingType: it.ListingType,
|
||||||
ActorIDs: strings.Join(actorIDs, ","),
|
ActorIDs: strings.Join(actorIDs, ","),
|
||||||
|
Condition: it.Condition,
|
||||||
|
Region: it.Region,
|
||||||
MaxResults: 30,
|
MaxResults: 30,
|
||||||
}
|
}
|
||||||
if cached, src, ok := a.Preview.Get(key); ok {
|
if cached, src, ok := a.Preview.Get(key); ok {
|
||||||
@@ -261,46 +276,16 @@ func (a *App) runPreview(ctx context.Context, it models.Item) ([]apify.UnifiedRe
|
|||||||
var merged []apify.UnifiedResult
|
var merged []apify.UnifiedResult
|
||||||
primarySource := ""
|
primarySource := ""
|
||||||
for _, p := range plans {
|
for _, p := range plans {
|
||||||
actorID := p.ActorID()
|
decoded, err := a.Scheduler.ExecutePlan(ctx, p)
|
||||||
if actorID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
raw, err := a.Apify.Run(ctx, actorID, p.Input())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("preview run failed", "actor", actorID, "query", p.Query(), "err", err)
|
slog.Warn("preview plan failed",
|
||||||
|
"provider", p.Provider(),
|
||||||
|
"marketplace", p.Marketplace(),
|
||||||
|
"query", p.Query(),
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
decoded, _ := apify.Decode(raw, p.Source())
|
|
||||||
for i := range decoded {
|
|
||||||
decoded[i].MatchedQuery = p.Query()
|
|
||||||
}
|
|
||||||
usable := 0
|
|
||||||
for _, r := range decoded {
|
|
||||||
if r.URL != "" && r.Price > 0 {
|
|
||||||
usable++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slog.Info("preview decoded",
|
|
||||||
"marketplace", previewMarket,
|
|
||||||
"actor", actorID,
|
|
||||||
"query", p.Query(),
|
|
||||||
"raw", len(raw),
|
|
||||||
"decoded", len(decoded),
|
|
||||||
"usable", usable,
|
|
||||||
)
|
|
||||||
if usable == 0 && len(raw) > 0 {
|
|
||||||
var sample map[string]any
|
|
||||||
if err := json.Unmarshal(raw[0], &sample); err == nil {
|
|
||||||
ks := make([]string, 0, len(sample))
|
|
||||||
for k := range sample {
|
|
||||||
ks = append(ks, k)
|
|
||||||
}
|
|
||||||
slog.Warn("preview decoded zero usable rows; raw item keys",
|
|
||||||
"actor", actorID,
|
|
||||||
"keys", ks,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
merged = append(merged, decoded...)
|
merged = append(merged, decoded...)
|
||||||
if primarySource == "" {
|
if primarySource == "" {
|
||||||
primarySource = p.Source()
|
primarySource = p.Source()
|
||||||
@@ -334,6 +319,8 @@ func formValuesFromItem(it models.Item, r *http.Request) templates.FormValues {
|
|||||||
IncludeOutOfStock: it.IncludeOutOfStock,
|
IncludeOutOfStock: it.IncludeOutOfStock,
|
||||||
Marketplaces: it.Marketplaces,
|
Marketplaces: it.Marketplaces,
|
||||||
ListingType: it.ListingType,
|
ListingType: it.ListingType,
|
||||||
|
Condition: it.Condition,
|
||||||
|
Region: it.Region,
|
||||||
ActorActive: it.ActorActive,
|
ActorActive: it.ActorActive,
|
||||||
ActorSold: it.ActorSold,
|
ActorSold: it.ActorSold,
|
||||||
ActorPriceCompare: it.ActorPriceCompare,
|
ActorPriceCompare: it.ActorPriceCompare,
|
||||||
@@ -349,7 +336,7 @@ func (a *App) PostCreateItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
id, err := a.Store.CreateItem(r.Context(), &it)
|
id, err := a.Store.CreateItem(r.Context(), &it)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "could not save item: "+err.Error(), http.StatusInternalServerError)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
it.ID = id
|
it.ID = id
|
||||||
@@ -391,7 +378,7 @@ func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
updated.ID = id
|
updated.ID = id
|
||||||
updated.Active = existing.Active
|
updated.Active = existing.Active
|
||||||
if err := a.Store.UpdateItem(r.Context(), &updated); err != nil {
|
if err := a.Store.UpdateItem(r.Context(), &updated); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.Scheduler.SyncItem(updated)
|
a.Scheduler.SyncItem(updated)
|
||||||
@@ -407,17 +394,18 @@ func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
it.Active = !it.Active
|
it.Active = !it.Active
|
||||||
if err := a.Store.SetItemActive(r.Context(), id, it.Active); err != nil {
|
if err := a.Store.SetItemActive(r.Context(), id, it.Active); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.Scheduler.SyncItem(*it)
|
a.Scheduler.SyncItem(*it)
|
||||||
render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context())))
|
hist, _ := a.Store.LoadRecentPriceHistory(r.Context(), []int64{id}, 20)
|
||||||
|
render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context()), hist[id]))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) {
|
func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) {
|
||||||
id := intParam(r, "id")
|
id := intParam(r, "id")
|
||||||
if err := a.Store.DeleteItem(r.Context(), id); err != nil {
|
if err := a.Store.DeleteItem(r.Context(), id); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.Scheduler.RemoveItem(id)
|
a.Scheduler.RemoveItem(id)
|
||||||
@@ -431,9 +419,31 @@ func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go a.Scheduler.RunPoll(context.Background(), *it)
|
|
||||||
// Re-render the row immediately so HTMX has something to swap in.
|
// Run synchronously so the response reflects the finished poll. Bounded so
|
||||||
render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context())))
|
// 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)
|
||||||
|
|
||||||
|
// A partial swap (single row or just the results table) leaves the rest
|
||||||
|
// of the page — best-price card, price chart, "last polled" time, badge —
|
||||||
|
// looking stale, so the run reads as a no-op. Tell htmx to do a full
|
||||||
|
// reload so every derived view picks up the post-poll state.
|
||||||
|
if r.Header.Get("HX-Request") != "" {
|
||||||
|
w.Header().Set("HX-Refresh", "true")
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-htmx fallback: redirect back to the originating page.
|
||||||
|
target := "/items"
|
||||||
|
if r.PostFormValue("from") == "results" {
|
||||||
|
target = fmt.Sprintf("/items/%d/results", id)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) {
|
func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -10,8 +10,13 @@ import (
|
|||||||
// previewKey caches the *raw* apify result set (post-decode, post-merge,
|
// previewKey caches the *raw* apify result set (post-decode, post-merge,
|
||||||
// pre-filter). Filters like min_price and exclude_keywords are applied after
|
// pre-filter). Filters like min_price and exclude_keywords are applied after
|
||||||
// the cache lookup so the operator can iterate on them without burning credits.
|
// the cache lookup so the operator can iterate on them without burning credits.
|
||||||
|
//
|
||||||
|
// Condition and Region are part of the key, not post-filters: they are
|
||||||
|
// server-side eBay Browse API filters that change the result set the API
|
||||||
|
// returns, so a different condition/region must miss the cache.
|
||||||
type previewKey struct {
|
type previewKey struct {
|
||||||
Queries, URL, Marketplace, ListingType, ActorIDs string
|
Queries, URL, Marketplace, ListingType, ActorIDs string
|
||||||
|
Condition, Region string
|
||||||
MaxResults int
|
MaxResults int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,20 +21,29 @@ func (a *App) GetItemResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||||
|
d, err := a.buildItemResultsData(r, it, page, r.URL.Query().Get("order"))
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, err)
|
||||||
|
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 == "" {
|
if order == "" {
|
||||||
order = "found_desc"
|
order = "found_desc"
|
||||||
}
|
}
|
||||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
total, err := a.Store.CountResults(r.Context(), id)
|
total, err := a.Store.CountResults(r.Context(), it.ID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
return templates.ItemResultsData{}, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
totalPages := (total + resultsPerPage - 1) / resultsPerPage
|
totalPages := (total + resultsPerPage - 1) / resultsPerPage
|
||||||
if totalPages < 1 {
|
if totalPages < 1 {
|
||||||
@@ -45,36 +54,37 @@ func (a *App) GetItemResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
|
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
|
||||||
ItemID: id,
|
ItemID: it.ID,
|
||||||
Limit: resultsPerPage,
|
Limit: resultsPerPage,
|
||||||
Offset: (page - 1) * resultsPerPage,
|
Offset: (page - 1) * resultsPerPage,
|
||||||
Order: order,
|
Order: order,
|
||||||
|
ExcludeEnded: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
return templates.ItemResultsData{}, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
history, err := a.Store.ListPriceHistory(r.Context(), id)
|
history, err := a.Store.ListPriceHistory(r.Context(), it.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
return templates.ItemResultsData{}, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
badge := scheduler.PickBadge(*it, history, time.Now())
|
// 24h surface for the "ending soon" strip — beyond that, a static
|
||||||
chart := buildChartJSON(history)
|
// "ends in 4 days" in the per-row cell carries enough signal on its own.
|
||||||
|
endingSoon, _ := a.Store.NextEndingResult(r.Context(), it.ID, 24*time.Hour)
|
||||||
|
|
||||||
render(w, r, templates.ItemResults(templates.ItemResultsData{
|
return templates.ItemResultsData{
|
||||||
Page: a.page(r, it.Name, "items"),
|
Page: a.page(r, it.Name, "items"),
|
||||||
Item: *it,
|
Item: *it,
|
||||||
Badge: badge,
|
Badge: scheduler.PickBadge(*it, history, time.Now()),
|
||||||
History: history,
|
History: history,
|
||||||
Results: results,
|
Results: results,
|
||||||
Page_: page,
|
Page_: page,
|
||||||
TotalPages: totalPages,
|
TotalPages: totalPages,
|
||||||
Order: order,
|
Order: order,
|
||||||
HistoryChartJSON: chart,
|
HistoryChartJSON: buildChartJSON(history),
|
||||||
}))
|
EndingSoon: endingSoon,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildChartJSON(history []models.PricePoint) string {
|
func buildChartJSON(history []models.PricePoint) string {
|
||||||
@@ -97,7 +107,7 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
items, err := a.Store.ListItems(r.Context())
|
items, err := a.Store.ListItems(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
names := make(map[int64]string, len(items))
|
names := make(map[int64]string, len(items))
|
||||||
@@ -106,11 +116,12 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
|
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
|
||||||
ItemID: itemID,
|
ItemID: itemID,
|
||||||
Limit: 200,
|
Limit: 200,
|
||||||
|
ExcludeEnded: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,12 +145,15 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
endingSoon, _ := a.Store.NextEndingResult(r.Context(), itemID, 24*time.Hour)
|
||||||
|
|
||||||
render(w, r, templates.GlobalResults(templates.GlobalResultsData{
|
render(w, r, templates.GlobalResults(templates.GlobalResultsData{
|
||||||
Page: a.page(r, "Results", "results"),
|
Page: a.page(r, "Results", "results"),
|
||||||
Items: items,
|
Items: items,
|
||||||
Results: rows,
|
Results: rows,
|
||||||
ItemID: itemID,
|
ItemID: itemID,
|
||||||
From: from,
|
From: from,
|
||||||
To: to,
|
To: to,
|
||||||
|
EndingSoon: endingSoon,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"veola/internal/apify"
|
"veola/internal/apify"
|
||||||
"veola/internal/auth"
|
"veola/internal/auth"
|
||||||
|
"veola/internal/ebay"
|
||||||
"veola/internal/models"
|
"veola/internal/models"
|
||||||
"veola/internal/ntfy"
|
"veola/internal/ntfy"
|
||||||
"veola/templates"
|
"veola/templates"
|
||||||
@@ -14,6 +16,9 @@ import (
|
|||||||
|
|
||||||
var settingsKeys = []string{
|
var settingsKeys = []string{
|
||||||
"apify_api_key",
|
"apify_api_key",
|
||||||
|
"ebay_client_id",
|
||||||
|
"ebay_client_secret",
|
||||||
|
"ebay_daily_call_limit",
|
||||||
"ntfy_base_url",
|
"ntfy_base_url",
|
||||||
"ntfy_default_topic",
|
"ntfy_default_topic",
|
||||||
"ntfy_token",
|
"ntfy_token",
|
||||||
@@ -21,6 +26,40 @@ var settingsKeys = []string{
|
|||||||
"match_confidence_threshold",
|
"match_confidence_threshold",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// secretSettingsKeys are credential fields. Their values are never rendered
|
||||||
|
// back into the form, so a blank submission means "leave unchanged" rather
|
||||||
|
// than "clear" — see PostSettings.
|
||||||
|
var secretSettingsKeys = map[string]bool{
|
||||||
|
"apify_api_key": true,
|
||||||
|
"ebay_client_id": true,
|
||||||
|
"ebay_client_secret": true,
|
||||||
|
"ntfy_token": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// credentialStatus reports, per secret key, whether a value is saved in the
|
||||||
|
// settings table, inherited from config.toml, or absent — without exposing
|
||||||
|
// the secret itself.
|
||||||
|
func (a *App) credentialStatus(values map[string]string) map[string]string {
|
||||||
|
configVals := map[string]string{
|
||||||
|
"apify_api_key": a.Cfg.Apify.APIKey,
|
||||||
|
"ebay_client_id": a.Cfg.Ebay.ClientID,
|
||||||
|
"ebay_client_secret": a.Cfg.Ebay.ClientSecret,
|
||||||
|
"ntfy_token": "",
|
||||||
|
}
|
||||||
|
status := make(map[string]string, len(secretSettingsKeys))
|
||||||
|
for k := range secretSettingsKeys {
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(values[k]) != "":
|
||||||
|
status[k] = "Saved in settings"
|
||||||
|
case strings.TrimSpace(configVals[k]) != "":
|
||||||
|
status[k] = "Set in config.toml"
|
||||||
|
default:
|
||||||
|
status[k] = "Not set"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
||||||
values, err := a.Store.GetAllSettings(r.Context())
|
values, err := a.Store.GetAllSettings(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -31,18 +70,22 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
|||||||
}
|
}
|
||||||
users, _ := a.Store.ListUsers(r.Context())
|
users, _ := a.Store.ListUsers(r.Context())
|
||||||
cur := auth.CurrentUserFromRequest(r)
|
cur := auth.CurrentUserFromRequest(r)
|
||||||
|
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
|
||||||
return templates.SettingsData{
|
return templates.SettingsData{
|
||||||
Page: a.page(r, "Settings", "settings"),
|
Page: a.page(r, "Settings", "settings"),
|
||||||
Values: values,
|
Values: values,
|
||||||
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
|
CredentialStatus: a.credentialStatus(values),
|
||||||
Users: users,
|
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
|
||||||
|
Users: users,
|
||||||
|
EbayUsedToday: ebayUsed,
|
||||||
|
EbayDailyLimit: ebayLimit,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) {
|
func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
d, err := a.settingsData(r)
|
d, err := a.settingsData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
render(w, r, templates.Settings(d))
|
render(w, r, templates.Settings(d))
|
||||||
@@ -60,8 +103,14 @@ func (a *App) PostSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
for _, k := range settingsKeys {
|
for _, k := range settingsKeys {
|
||||||
v := strings.TrimSpace(r.PostFormValue(k))
|
v := strings.TrimSpace(r.PostFormValue(k))
|
||||||
|
// Secret fields are never rendered back into the form, so a blank
|
||||||
|
// submission is the normal state and means "leave unchanged" — not
|
||||||
|
// "clear". (To clear a stored credential, edit the settings table.)
|
||||||
|
if v == "" && secretSettingsKeys[k] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := a.Store.SetSetting(r.Context(), k, v); err != nil {
|
if err := a.Store.SetSetting(r.Context(), k, v); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,7 +133,7 @@ func (a *App) PostPasswordChange(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
d, err := a.settingsData(r)
|
d, err := a.settingsData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +156,7 @@ func (a *App) PostPasswordChange(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.Store.UpdateUserPassword(r.Context(), cur.ID, hash); err != nil {
|
if err := a.Store.UpdateUserPassword(r.Context(), cur.ID, hash); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d.PasswordMsg = "Password updated"
|
d.PasswordMsg = "Password updated"
|
||||||
@@ -122,7 +171,7 @@ func (a *App) PostTestNtfy(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
d, err := a.settingsData(r)
|
d, err := a.settingsData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
baseURL := strings.TrimSpace(d.Values["ntfy_base_url"])
|
baseURL := strings.TrimSpace(d.Values["ntfy_base_url"])
|
||||||
@@ -156,7 +205,7 @@ func (a *App) PostTestApify(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
d, err := a.settingsData(r)
|
d, err := a.settingsData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
apiKey := strings.TrimSpace(d.Values["apify_api_key"])
|
apiKey := strings.TrimSpace(d.Values["apify_api_key"])
|
||||||
@@ -193,3 +242,90 @@ func (a *App) PostTestApify(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
render(w, r, templates.Settings(d))
|
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 {
|
||||||
|
a.serverError(w, r, err)
|
||||||
|
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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
func (a *App) renderSettingsWithUserMsg(w http.ResponseWriter, r *http.Request, msg, errMsg string) {
|
func (a *App) renderSettingsWithUserMsg(w http.ResponseWriter, r *http.Request, msg, errMsg string) {
|
||||||
d, err := a.settingsData(r)
|
d, err := a.settingsData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d.UserMsg = msg
|
d.UserMsg = msg
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ type Item struct {
|
|||||||
ExcludeKeywords string
|
ExcludeKeywords string
|
||||||
Marketplaces []string
|
Marketplaces []string
|
||||||
ListingType string
|
ListingType string
|
||||||
|
// Condition and Region are eBay-only search filters. Condition is
|
||||||
|
// Veola's vocabulary ("new", "used", "refurbished", "parts"); Region is
|
||||||
|
// an ISO 3166-1 alpha-2 country code constraining item location. Both
|
||||||
|
// empty means no filter, and both are ignored for non-eBay marketplaces.
|
||||||
|
Condition string
|
||||||
|
Region string
|
||||||
ActorActive string
|
ActorActive string
|
||||||
ActorSold string
|
ActorSold string
|
||||||
ActorPriceCompare string
|
ActorPriceCompare string
|
||||||
@@ -43,6 +49,7 @@ type Item struct {
|
|||||||
LastPolledAt *time.Time
|
LastPolledAt *time.Time
|
||||||
LastPollError string
|
LastPollError string
|
||||||
BestPrice *float64
|
BestPrice *float64
|
||||||
|
BestPriceCurrency string
|
||||||
BestPriceStore string
|
BestPriceStore string
|
||||||
BestPriceURL string
|
BestPriceURL string
|
||||||
BestPriceImageURL string
|
BestPriceImageURL string
|
||||||
@@ -63,6 +70,10 @@ type Result struct {
|
|||||||
MatchedQuery string
|
MatchedQuery string
|
||||||
Alerted bool
|
Alerted bool
|
||||||
FoundAt time.Time
|
FoundAt time.Time
|
||||||
|
// EndsAt is populated for auction-format listings (eBay auctions,
|
||||||
|
// Yahoo Auctions JP). Nil for fixed-price listings and for any source
|
||||||
|
// that doesn't report an end time.
|
||||||
|
EndsAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchQueries returns the item's alias list. Splits on newline, comma, and
|
// SearchQueries returns the item's alias list. Splits on newline, comma, and
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,14 +14,23 @@ import (
|
|||||||
"veola/internal/apify"
|
"veola/internal/apify"
|
||||||
"veola/internal/config"
|
"veola/internal/config"
|
||||||
"veola/internal/db"
|
"veola/internal/db"
|
||||||
|
"veola/internal/ebay"
|
||||||
"veola/internal/models"
|
"veola/internal/models"
|
||||||
"veola/internal/ntfy"
|
"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 {
|
type Scheduler struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
store *db.Store
|
store *db.Store
|
||||||
apify *apify.Client
|
apify *apify.Client
|
||||||
|
ebay *ebay.Client
|
||||||
ntfy *ntfy.Client
|
ntfy *ntfy.Client
|
||||||
cron *cron.Cron
|
cron *cron.Cron
|
||||||
|
|
||||||
@@ -37,6 +47,7 @@ func New(cfg *config.Config, store *db.Store, ap *apify.Client, nt *ntfy.Client)
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
store: store,
|
store: store,
|
||||||
apify: ap,
|
apify: ap,
|
||||||
|
ebay: ebay.New(cfg.Ebay.ClientID, cfg.Ebay.ClientSecret, cfg.Ebay.Environment),
|
||||||
ntfy: nt,
|
ntfy: nt,
|
||||||
cron: cron.New(),
|
cron: cron.New(),
|
||||||
entries: make(map[int64]cron.EntryID),
|
entries: make(map[int64]cron.EntryID),
|
||||||
@@ -136,52 +147,16 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
|
|||||||
var errs []string
|
var errs []string
|
||||||
successes := 0
|
successes := 0
|
||||||
for _, p := range plans {
|
for _, p := range plans {
|
||||||
if p.actorID == "" {
|
decoded, err := s.ExecutePlan(ctx, p)
|
||||||
errs = append(errs, fmt.Sprintf("%s: no actor configured", p.marketplace))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
raw, err := apifyClient.Run(ctx, p.actorID, p.input)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
label := p.marketplace
|
label := p.marketplace
|
||||||
if p.query != "" {
|
if p.query != "" {
|
||||||
label = fmt.Sprintf("query %q on %s", p.query, p.marketplace)
|
label = fmt.Sprintf("query %q on %s", p.query, p.marketplace)
|
||||||
}
|
}
|
||||||
errs = append(errs, fmt.Sprintf("%s: %s", label, err.Error()))
|
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
|
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...)
|
results = append(results, decoded...)
|
||||||
successes++
|
successes++
|
||||||
}
|
}
|
||||||
@@ -245,6 +220,14 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if exists {
|
if exists {
|
||||||
|
// Row already stored — but if this poll surfaced an end time we
|
||||||
|
// didn't have before (or the row predates the ends_at column),
|
||||||
|
// backfill it so countdowns light up for known auctions.
|
||||||
|
if r.EndsAt != nil {
|
||||||
|
if err := s.store.BackfillResultEndsAt(ctx, it.ID, r.URL, *r.EndsAt); err != nil {
|
||||||
|
slog.Error("backfill ends_at failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
alerted := false
|
alerted := false
|
||||||
@@ -267,6 +250,7 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
|
|||||||
ImageURL: r.ImageURL,
|
ImageURL: r.ImageURL,
|
||||||
MatchedQuery: r.MatchedQuery,
|
MatchedQuery: r.MatchedQuery,
|
||||||
Alerted: alerted,
|
Alerted: alerted,
|
||||||
|
EndsAt: r.EndsAt,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("insert result failed", "err", err)
|
slog.Error("insert result failed", "err", err)
|
||||||
@@ -282,6 +266,7 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
|
|||||||
bp := best.Price
|
bp := best.Price
|
||||||
_ = s.store.UpdateItemPollResult(ctx, it.ID, &models.Item{
|
_ = s.store.UpdateItemPollResult(ctx, it.ID, &models.Item{
|
||||||
BestPrice: &bp,
|
BestPrice: &bp,
|
||||||
|
BestPriceCurrency: best.Currency,
|
||||||
BestPriceStore: best.Store,
|
BestPriceStore: best.Store,
|
||||||
BestPriceURL: best.URL,
|
BestPriceURL: best.URL,
|
||||||
BestPriceImageURL: best.ImageURL,
|
BestPriceImageURL: best.ImageURL,
|
||||||
@@ -322,6 +307,104 @@ func (s *Scheduler) apifyClient(ctx context.Context) *apify.Client {
|
|||||||
return apify.New(key)
|
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,
|
||||||
|
EndsAt: l.EndsAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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 {
|
func (s *Scheduler) sendNotification(ctx context.Context, it models.Item, r apify.UnifiedResult) error {
|
||||||
tags := []string{"mag"}
|
tags := []string{"mag"}
|
||||||
if it.TargetPrice != nil && r.Price <= *it.TargetPrice {
|
if it.TargetPrice != nil && r.Price <= *it.TargetPrice {
|
||||||
@@ -398,6 +481,7 @@ func (s *Scheduler) BuildPreviewInputs(it models.Item) []actorPlan {
|
|||||||
type actorPlan struct {
|
type actorPlan struct {
|
||||||
marketplace string
|
marketplace string
|
||||||
source string
|
source string
|
||||||
|
provider string
|
||||||
actorID string
|
actorID string
|
||||||
query string
|
query string
|
||||||
input any
|
input any
|
||||||
@@ -418,6 +502,9 @@ func (p actorPlan) Query() string { return p.query }
|
|||||||
// Input returns the actor input payload as expected by apify.Client.Run.
|
// Input returns the actor input payload as expected by apify.Client.Run.
|
||||||
func (p actorPlan) Input() any { return p.input }
|
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.
|
// buildAllInputs returns one actor plan per (alias × marketplace) for the item.
|
||||||
// For URL-only items (no aliases), produces one plan per marketplace with an
|
// For URL-only items (no aliases), produces one plan per marketplace with an
|
||||||
// empty query string.
|
// empty query string.
|
||||||
@@ -447,27 +534,53 @@ func (s *Scheduler) buildInputsForQuery(it models.Item, query string, markets []
|
|||||||
switch {
|
switch {
|
||||||
case strings.Contains(mk, "yahoo") || strings.Contains(url, "yahoo.co.jp"):
|
case strings.Contains(mk, "yahoo") || strings.Contains(url, "yahoo.co.jp"):
|
||||||
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.YahooAuctionsJP)
|
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.YahooAuctionsJP)
|
||||||
plans = append(plans, actorPlan{m, apify.SourceYahooJP, actorID, query, apify.YahooAuctionsJPInput{
|
plans = append(plans, actorPlan{
|
||||||
SearchTerm: query,
|
marketplace: m, source: apify.SourceYahooJP, provider: providerApify,
|
||||||
MaxPages: 1,
|
actorID: actorID, query: query,
|
||||||
}})
|
input: apify.YahooAuctionsJPInput{SearchTerm: query, MaxPages: 1},
|
||||||
|
})
|
||||||
case strings.Contains(mk, "mercari") || strings.Contains(url, "mercari"):
|
case strings.Contains(mk, "mercari") || strings.Contains(url, "mercari"):
|
||||||
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.MercariJP)
|
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.MercariJP)
|
||||||
plans = append(plans, actorPlan{m, apify.SourceMercariJP, actorID, query, apify.MercariJPInput{
|
plans = append(plans, actorPlan{
|
||||||
SearchKeywords: []string{query},
|
marketplace: m, source: apify.SourceMercariJP, provider: providerApify,
|
||||||
Status: "on_sale",
|
actorID: actorID, query: query,
|
||||||
MaxResults: 30,
|
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,
|
||||||
|
Condition: it.Condition,
|
||||||
|
Region: it.Region,
|
||||||
|
Limit: 30,
|
||||||
|
},
|
||||||
|
})
|
||||||
default:
|
default:
|
||||||
|
// Non-eBay custom marketplaces still fall back to the Apify
|
||||||
|
// active-listings actor.
|
||||||
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.ActiveListings)
|
actorID := firstNonEmpty(it.ActorActive, s.cfg.Apify.Actors.ActiveListings)
|
||||||
plans = append(plans, actorPlan{m, apify.SourceActiveEbay, actorID, query, apify.ActiveListingInput{
|
plans = append(plans, actorPlan{
|
||||||
SearchQueries: []string{query},
|
marketplace: m, source: apify.SourceActiveEbay, provider: providerApify,
|
||||||
MaxProductsPerSearch: 30,
|
actorID: actorID, query: query,
|
||||||
MaxSearchPages: 1,
|
input: apify.ActiveListingInput{
|
||||||
Sort: "best_match",
|
SearchQueries: []string{query},
|
||||||
ListingType: mapListingType(it.ListingType),
|
MaxProductsPerSearch: 30,
|
||||||
ProxyConfiguration: s.proxyConfig(),
|
MaxSearchPages: 1,
|
||||||
}})
|
Sort: "best_match",
|
||||||
|
ListingType: mapListingType(it.ListingType),
|
||||||
|
ProxyConfiguration: s.proxyConfig(),
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return plans
|
return plans
|
||||||
|
|||||||
19
main.go
19
main.go
@@ -11,6 +11,9 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"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/apify"
|
||||||
"veola/internal/auth"
|
"veola/internal/auth"
|
||||||
@@ -24,8 +27,19 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("config", "config.toml", "path to config TOML file")
|
configPath := flag.String("config", "config.toml", "path to config TOML file")
|
||||||
|
debug := flag.Bool("debug", false, "enable debug-level logging (verbose; raw external payloads logged)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if *debug {
|
||||||
|
level = slog.LevelDebug
|
||||||
|
}
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})))
|
||||||
|
|
||||||
|
if *debug {
|
||||||
|
slog.Debug("debug logging enabled")
|
||||||
|
}
|
||||||
|
|
||||||
if err := run(*configPath); err != nil {
|
if err := run(*configPath); err != nil {
|
||||||
slog.Error("fatal", "err", err)
|
slog.Error("fatal", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -50,7 +64,7 @@ func run(configPath string) error {
|
|||||||
defer sqlDB.Close()
|
defer sqlDB.Close()
|
||||||
|
|
||||||
store := db.NewStore(sqlDB, key)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("auth manager: %w", err)
|
return fmt.Errorf("auth manager: %w", err)
|
||||||
}
|
}
|
||||||
@@ -71,6 +85,9 @@ func run(configPath string) error {
|
|||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: app.Routes(),
|
Handler: app.Routes(),
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 60 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
|
|||||||
@@ -26,16 +26,54 @@ a { color: var(--accent); }
|
|||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.v-card {
|
.v-card {
|
||||||
background: var(--surface);
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(36, 58, 147, 0.82), rgba(31, 51, 128, 0.82));
|
||||||
|
backdrop-filter: blur(10px) saturate(140%);
|
||||||
|
-webkit-backdrop-filter: blur(10px) saturate(140%);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
|
transition:
|
||||||
|
transform 180ms ease,
|
||||||
|
box-shadow 180ms ease,
|
||||||
|
border-color 180ms ease;
|
||||||
|
}
|
||||||
|
.v-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
rgba(0, 164, 228, 0.65) 0%,
|
||||||
|
rgba(245, 196, 0, 0.30) 45%,
|
||||||
|
rgba(255, 255, 255, 0.04) 100%);
|
||||||
|
-webkit-mask:
|
||||||
|
linear-gradient(#000 0 0) content-box,
|
||||||
|
linear-gradient(#000 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
.v-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 28px rgba(0, 0, 80, 0.55),
|
||||||
|
0 0 0 1px rgba(0, 164, 228, 0.30);
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-card-flat {
|
.v-card-flat {
|
||||||
background: var(--surface);
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(36, 58, 147, 0.70), rgba(31, 51, 128, 0.70));
|
||||||
|
backdrop-filter: blur(8px) saturate(130%);
|
||||||
|
-webkit-backdrop-filter: blur(8px) saturate(130%);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-divider { border-top: 1px solid var(--border); }
|
.v-divider { border-top: 1px solid var(--border); }
|
||||||
@@ -138,6 +176,19 @@ a:hover { text-decoration: underline; }
|
|||||||
}
|
}
|
||||||
.v-side-nav a:hover { color: white; }
|
.v-side-nav a:hover { color: white; }
|
||||||
|
|
||||||
|
/* The brand wordmark at the top of the sidebar is also an anchor (→ /), but
|
||||||
|
shouldn't pick up the active-item border-left / padding treatment that
|
||||||
|
the nav links get. Higher specificity overrides .v-side-nav a defaults. */
|
||||||
|
.v-side-nav a.v-side-brand {
|
||||||
|
border-left: 0;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.v-side-nav a.v-side-brand:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
.v-veola-portrait {
|
.v-veola-portrait {
|
||||||
background: #f3ead8;
|
background: #f3ead8;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -173,7 +224,9 @@ table.v-table td {
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
table.v-table tr:hover td { background: rgba(255,255,255,0.03); }
|
table.v-table td { transition: background 140ms ease; }
|
||||||
|
table.v-table tr { transition: transform 140ms ease; }
|
||||||
|
table.v-table tbody tr:hover td { background: rgba(0, 164, 228, 0.08); }
|
||||||
|
|
||||||
.v-error-text { color: var(--danger); font-size: 0.85rem; }
|
.v-error-text { color: var(--danger); font-size: 0.85rem; }
|
||||||
.v-muted { color: var(--text-2); }
|
.v-muted { color: var(--text-2); }
|
||||||
@@ -193,11 +246,6 @@ table.v-table tr:hover td { background: rgba(255,255,255,0.03); }
|
|||||||
margin-bottom: 1rem;
|
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 {
|
.v-spinner {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 14px; height: 14px;
|
width: 14px; height: 14px;
|
||||||
@@ -207,3 +255,222 @@ table.v-table tr:hover td { background: rgba(255,255,255,0.03); }
|
|||||||
animation: v-spin 0.8s linear infinite;
|
animation: v-spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes v-spin { to { transform: rotate(360deg); } }
|
@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; }
|
||||||
|
|
||||||
|
/* flair.js adds .v-just-swapped to any htmx swap target for ~400ms, giving
|
||||||
|
refreshed regions a soft fade-in instead of an abrupt content jump. */
|
||||||
|
@keyframes v-fade-in-up {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.v-just-swapped { animation: v-fade-in-up 240ms ease-out; }
|
||||||
|
|
||||||
|
/* Ending-soon strip: one global "next auction to close" banner. flair.js
|
||||||
|
keeps the countdown live. Subtle by default; pulses red when inside the
|
||||||
|
last 5 minutes via .v-countdown-critical on the inner counter. */
|
||||||
|
.v-ending-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(245, 196, 0, 0.12), rgba(0, 164, 228, 0.08));
|
||||||
|
border: 1px solid rgba(245, 196, 0, 0.35);
|
||||||
|
}
|
||||||
|
.v-ending-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--yellow);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.v-ending-title { flex: 1; min-width: 0; }
|
||||||
|
.v-ending-countdown {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Per-row + strip countdown urgency states. Default reads as neutral muted
|
||||||
|
text so 6-day countdowns don't shout; urgency tints kick in only when
|
||||||
|
flair.js flips the class. */
|
||||||
|
.v-countdown { color: var(--text-2); }
|
||||||
|
.v-countdown-urgent { color: var(--yellow); font-weight: 600; }
|
||||||
|
.v-countdown-critical {
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 700;
|
||||||
|
animation: v-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.v-countdown-ended { color: var(--text-2); font-style: italic; }
|
||||||
|
|
||||||
|
@keyframes v-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.55; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sparkline cells in the items list. Color follows trend: green when the
|
||||||
|
latest price is meaningfully below the running average (good news for a
|
||||||
|
watchlist), red when it's risen, neutral otherwise. */
|
||||||
|
.v-sparkline { display: block; overflow: visible; }
|
||||||
|
.v-spark-down { color: var(--success); filter: drop-shadow(0 0 4px rgba(0, 228, 164, 0.45)); }
|
||||||
|
.v-spark-up { color: var(--danger); filter: drop-shadow(0 0 4px rgba(232, 64, 64, 0.40)); }
|
||||||
|
.v-spark-flat { color: var(--text-2); }
|
||||||
|
|
||||||
|
/* Trend arrow rendered next to Best Price. Same palette as the sparkline,
|
||||||
|
so a glance at the column reads consistently. */
|
||||||
|
.v-trend { font-size: 0.95rem; font-weight: 700; }
|
||||||
|
.v-trend-down { color: var(--success); }
|
||||||
|
.v-trend-up { color: var(--danger); }
|
||||||
|
.v-trend-flat { color: var(--text-2); }
|
||||||
|
|
||||||
|
/* Mascot "deal" moment: Veola appears next to an item's name only when the
|
||||||
|
current best price is at or below target. Small, animated, decorative —
|
||||||
|
purely a delight hit on top of the existing "Deal" badge. */
|
||||||
|
.v-deal-mascot {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #f3ead8;
|
||||||
|
padding: 1px;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 228, 164, 0.6), 0 0 12px rgba(0, 228, 164, 0.45);
|
||||||
|
animation: v-deal-bob 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes v-deal-bob {
|
||||||
|
0%, 100% { transform: translateY(0) rotate(-2deg); }
|
||||||
|
50% { transform: translateY(-2px) rotate(2deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.v-deal-mascot { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Login / Setup chrome -----------------------------------------------
|
||||||
|
The auth pages use the Bare layout (no sidebar) so the form has to carry
|
||||||
|
its own visual weight. The v-auth-* classes give it that: a glassy card
|
||||||
|
for the form, a softly-rotating conic halo behind the mascot, a
|
||||||
|
typeset wordmark + tagline, and the right column is left translucent so
|
||||||
|
the global aurora reaches edge-to-edge instead of stopping at a flat
|
||||||
|
blue block. */
|
||||||
|
|
||||||
|
.v-auth-wordmark {
|
||||||
|
font-family: 'Outfit', system-ui, sans-serif;
|
||||||
|
text-shadow: 0 2px 14px rgba(0, 164, 228, 0.30);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-auth-card {
|
||||||
|
/* Slightly heavier shadow + inner glow ring since the card stands
|
||||||
|
alone on the page with no sidebar context. */
|
||||||
|
box-shadow:
|
||||||
|
0 16px 44px rgba(0, 0, 80, 0.55),
|
||||||
|
0 0 0 1px rgba(0, 164, 228, 0.18),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-auth-tagline {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.34em;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-2);
|
||||||
|
text-shadow: 0 0 12px rgba(0, 164, 228, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-auth-portrait-col {
|
||||||
|
/* Translucent overlay instead of a solid block: a soft inward vignette
|
||||||
|
plus a faint diagonal sheen, keeping the column distinct from the
|
||||||
|
form side without hiding the aurora behind it. */
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at center, rgba(0, 0, 0, 0.0) 30%, rgba(0, 0, 0, 0.25) 100%),
|
||||||
|
linear-gradient(135deg, rgba(0, 164, 228, 0.05) 0%, rgba(245, 196, 0, 0.04) 100%);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-auth-portrait-halo {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
width: min(420px, 80%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: v-auth-portrait-float 7s ease-in-out infinite;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rotating conic-gradient halo. Sits behind the portrait via z-index -1
|
||||||
|
inside the isolated stacking context, blurred so it reads as a glow
|
||||||
|
rather than a hard rainbow. */
|
||||||
|
.v-auth-portrait-halo::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -15%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: conic-gradient(
|
||||||
|
from 180deg,
|
||||||
|
rgba(0, 164, 228, 0.55),
|
||||||
|
rgba(245, 196, 0, 0.40),
|
||||||
|
rgba(232, 64, 64, 0.30),
|
||||||
|
rgba(0, 228, 164, 0.40),
|
||||||
|
rgba(0, 164, 228, 0.55)
|
||||||
|
);
|
||||||
|
filter: blur(50px);
|
||||||
|
z-index: -1;
|
||||||
|
animation: v-auth-portrait-spin 22s linear infinite;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A second, slower halo gives the impression of depth: two layers of
|
||||||
|
light rotating at different speeds keep the eye from locking onto a
|
||||||
|
single repeating pattern. */
|
||||||
|
.v-auth-portrait-halo::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -25%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: conic-gradient(
|
||||||
|
from 0deg,
|
||||||
|
rgba(0, 228, 164, 0.18),
|
||||||
|
rgba(0, 164, 228, 0.10),
|
||||||
|
rgba(245, 196, 0, 0.14),
|
||||||
|
rgba(0, 228, 164, 0.18)
|
||||||
|
);
|
||||||
|
filter: blur(70px);
|
||||||
|
z-index: -1;
|
||||||
|
animation: v-auth-portrait-spin 42s linear infinite reverse;
|
||||||
|
will-change: transform;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes v-auth-portrait-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes v-auth-portrait-float {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-6px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.v-auth-portrait-halo,
|
||||||
|
.v-auth-portrait-halo::before,
|
||||||
|
.v-auth-portrait-halo::after { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.v-card { transition: none; }
|
||||||
|
.v-card:hover { transform: none; }
|
||||||
|
table.v-table td, table.v-table tr { transition: none; }
|
||||||
|
.v-just-swapped { animation: none; }
|
||||||
|
.v-countdown-critical { animation: none; }
|
||||||
|
}
|
||||||
|
|||||||
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;
|
||||||
118
static/css/retro.css
Normal file
118
static/css/retro.css
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/* retro.css — optional "retro-arcade identity" layer.
|
||||||
|
*
|
||||||
|
* Entirely additive. To revert, delete this file and remove its <link> tag
|
||||||
|
* from templates/layout.templ. Nothing else depends on it.
|
||||||
|
*
|
||||||
|
* What this adds:
|
||||||
|
* 1. Two independently-animated aurora blobs that drift across the
|
||||||
|
* viewport on different paths — the real "shifting light" feel,
|
||||||
|
* not a synchronized slide.
|
||||||
|
* 2. Dot-grid overlay over the whole viewport.
|
||||||
|
* 3. Heavier display headings with a soft accent glow.
|
||||||
|
*
|
||||||
|
* Both blobs live on html pseudo-elements at z-index -2 / -1, sitting
|
||||||
|
* behind everything else so card glassiness still reads on top.
|
||||||
|
*/
|
||||||
|
|
||||||
|
body {
|
||||||
|
/* Let the html-level aurora show through. app.css set a solid blue here;
|
||||||
|
dropping it here unlocks the gradient. Removing retro.css restores the
|
||||||
|
original solid background automatically. */
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background-color: var(--bg);
|
||||||
|
/* Static dot-grid baked into the root background so the two pseudo-element
|
||||||
|
slots stay free for the animated blobs. */
|
||||||
|
background-image: radial-gradient(
|
||||||
|
circle,
|
||||||
|
rgba(255, 255, 255, 0.045) 1px,
|
||||||
|
transparent 1px
|
||||||
|
);
|
||||||
|
background-size: 26px 26px;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two aurora blobs animated with seven waypoints each (no `alternate`, so
|
||||||
|
the second half doesn't mirror the first) on incommensurate periods —
|
||||||
|
the two cycles drift in and out of phase, so the eye never locks onto a
|
||||||
|
repeating pattern even though each blob is technically on a loop.
|
||||||
|
Opacity also wobbles so the lights "breathe" instead of just sliding. */
|
||||||
|
|
||||||
|
html::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -2;
|
||||||
|
pointer-events: none;
|
||||||
|
background: radial-gradient(
|
||||||
|
closest-side,
|
||||||
|
rgba(0, 164, 228, 0.60),
|
||||||
|
rgba(0, 164, 228, 0.0) 70%
|
||||||
|
);
|
||||||
|
width: 70vmax;
|
||||||
|
height: 70vmax;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(60px);
|
||||||
|
/* Centered base; keyframes drive all motion from here. */
|
||||||
|
top: 50%; left: 50%;
|
||||||
|
margin-top: -35vmax; margin-left: -35vmax;
|
||||||
|
animation: v-retro-drift-a 41s cubic-bezier(.6,.1,.4,.9) infinite;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
html::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -2;
|
||||||
|
pointer-events: none;
|
||||||
|
background: radial-gradient(
|
||||||
|
closest-side,
|
||||||
|
rgba(245, 196, 0, 0.50),
|
||||||
|
rgba(245, 196, 0, 0.0) 70%
|
||||||
|
);
|
||||||
|
width: 55vmax;
|
||||||
|
height: 55vmax;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(70px);
|
||||||
|
top: 50%; left: 50%;
|
||||||
|
margin-top: -27.5vmax; margin-left: -27.5vmax;
|
||||||
|
animation: v-retro-drift-b 29s cubic-bezier(.5,.2,.3,.8) infinite;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes v-retro-drift-a {
|
||||||
|
0% { transform: translate3d(-30vw, -20vh, 0) scale(1.00); opacity: 0.85; }
|
||||||
|
14% { transform: translate3d( 5vw, -28vh, 0) scale(1.18); opacity: 1.00; }
|
||||||
|
29% { transform: translate3d( 32vw, -10vh, 0) scale(0.92); opacity: 0.75; }
|
||||||
|
43% { transform: translate3d( 18vw, 22vh, 0) scale(1.10); opacity: 0.95; }
|
||||||
|
58% { transform: translate3d(-15vw, 30vh, 0) scale(1.05); opacity: 0.80; }
|
||||||
|
72% { transform: translate3d(-38vw, 8vh, 0) scale(0.95); opacity: 1.00; }
|
||||||
|
86% { transform: translate3d(-20vw, -12vh, 0) scale(1.12); opacity: 0.90; }
|
||||||
|
100% { transform: translate3d(-30vw, -20vh, 0) scale(1.00); opacity: 0.85; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes v-retro-drift-b {
|
||||||
|
0% { transform: translate3d( 28vw, -25vh, 0) scale(1.00); opacity: 0.70; }
|
||||||
|
17% { transform: translate3d( 0vw, -10vh, 0) scale(1.20); opacity: 0.95; }
|
||||||
|
33% { transform: translate3d(-30vw, 8vh, 0) scale(0.90); opacity: 0.80; }
|
||||||
|
48% { transform: translate3d(-12vw, 28vh, 0) scale(1.10); opacity: 1.00; }
|
||||||
|
64% { transform: translate3d( 22vw, 18vh, 0) scale(0.95); opacity: 0.75; }
|
||||||
|
80% { transform: translate3d( 36vw, -2vh, 0) scale(1.15); opacity: 0.90; }
|
||||||
|
100% { transform: translate3d( 28vw, -25vh, 0) scale(1.00); opacity: 0.70; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Display headings: heavier, slightly tighter, with an accent glow that ties
|
||||||
|
into the card border gradient. Class-free selectors so existing Tailwind
|
||||||
|
utilities (text-3xl etc.) stack on top untouched. */
|
||||||
|
h1, h2 {
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
text-shadow: 0 2px 14px rgba(0, 164, 228, 0.30);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html::before, html::after { animation: none; }
|
||||||
|
}
|
||||||
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
BIN
static/img/veola.avif
Normal file
BIN
static/img/veola.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB |
130
static/js/flair.js
Normal file
130
static/js/flair.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// Lightweight visual flourishes:
|
||||||
|
// - count-up animation on [data-countup] elements at page load
|
||||||
|
// - fade-in on htmx swap targets via a transient .v-just-swapped class
|
||||||
|
// Respects prefers-reduced-motion by no-oping both effects.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const prefersReduced =
|
||||||
|
window.matchMedia &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
function easeOutCubic(t) {
|
||||||
|
return 1 - Math.pow(1 - t, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateCount(el) {
|
||||||
|
if (prefersReduced) return;
|
||||||
|
const raw = el.textContent.trim();
|
||||||
|
const m = raw.match(/^(\$|£|€|¥)?(-?[\d,]+(?:\.\d+)?)$/);
|
||||||
|
if (!m) return;
|
||||||
|
const prefix = m[1] || "";
|
||||||
|
const numeric = m[2].replace(/,/g, "");
|
||||||
|
const target = parseFloat(numeric);
|
||||||
|
if (!isFinite(target)) return;
|
||||||
|
const decimals = numeric.includes(".") ? numeric.split(".")[1].length : 0;
|
||||||
|
const format = (v) =>
|
||||||
|
prefix + (decimals > 0 ? v.toFixed(decimals) : Math.floor(v).toString());
|
||||||
|
const duration = 650;
|
||||||
|
const start = performance.now();
|
||||||
|
el.textContent = format(0);
|
||||||
|
function tick(now) {
|
||||||
|
const t = Math.min(1, (now - start) / duration);
|
||||||
|
const v = target * easeOutCubic(t);
|
||||||
|
el.textContent = format(v);
|
||||||
|
if (t < 1) {
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
} else {
|
||||||
|
el.textContent = format(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCountUps() {
|
||||||
|
document.querySelectorAll("[data-countup]").forEach(animateCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", runCountUps);
|
||||||
|
} else {
|
||||||
|
runCountUps();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("htmx:afterSwap", function (evt) {
|
||||||
|
if (prefersReduced) return;
|
||||||
|
const target = evt.detail && evt.detail.target;
|
||||||
|
if (!target || !target.classList) return;
|
||||||
|
target.classList.add("v-just-swapped");
|
||||||
|
setTimeout(() => target.classList.remove("v-just-swapped"), 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auction countdowns. Each [data-countdown] reads its sibling/ancestor's
|
||||||
|
// data-ends-at ISO timestamp. Above 1h remaining we update once a minute
|
||||||
|
// (text barely changes); inside the last hour we tick every second and tint
|
||||||
|
// urgent / critical so the eye lands on it. Past the end we render "ended"
|
||||||
|
// and stop ticking that node.
|
||||||
|
function formatRemaining(ms) {
|
||||||
|
if (ms <= 0) return { text: "ended", urgent: false, critical: false };
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
const days = Math.floor(s / 86400);
|
||||||
|
const hours = Math.floor((s % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((s % 3600) / 60);
|
||||||
|
const seconds = s % 60;
|
||||||
|
const urgent = ms < 60 * 60 * 1000;
|
||||||
|
const critical = ms < 5 * 60 * 1000;
|
||||||
|
let text;
|
||||||
|
if (days > 0) text = days + "d " + hours + "h";
|
||||||
|
else if (hours > 0) text = hours + "h " + minutes + "m";
|
||||||
|
else if (minutes > 0) text = minutes + "m " + String(seconds).padStart(2, "0") + "s";
|
||||||
|
else text = seconds + "s";
|
||||||
|
return { text, urgent, critical };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEndsAt(el) {
|
||||||
|
let cur = el;
|
||||||
|
while (cur && cur.dataset && !cur.dataset.endsAt) cur = cur.parentElement;
|
||||||
|
return cur && cur.dataset ? cur.dataset.endsAt : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickCountdown(el, endsAtMs) {
|
||||||
|
const remaining = endsAtMs - Date.now();
|
||||||
|
const f = formatRemaining(remaining);
|
||||||
|
el.textContent = f.text;
|
||||||
|
el.classList.toggle("v-countdown-urgent", f.urgent && !f.critical);
|
||||||
|
el.classList.toggle("v-countdown-critical", f.critical && remaining > 0);
|
||||||
|
el.classList.toggle("v-countdown-ended", remaining <= 0);
|
||||||
|
return remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdowns() {
|
||||||
|
document.querySelectorAll("[data-countdown]").forEach(function (el) {
|
||||||
|
const iso = resolveEndsAt(el);
|
||||||
|
if (!iso) return;
|
||||||
|
const endsAtMs = Date.parse(iso);
|
||||||
|
if (!isFinite(endsAtMs)) return;
|
||||||
|
|
||||||
|
let timer = null;
|
||||||
|
function loop() {
|
||||||
|
const remaining = tickCountdown(el, endsAtMs);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Tick every second below an hour, every 30 seconds otherwise. The
|
||||||
|
// long-interval path keeps "3d 4h" from causing constant repaints.
|
||||||
|
const interval = remaining < 60 * 60 * 1000 ? 1000 : 30000;
|
||||||
|
timer = setTimeout(loop, interval);
|
||||||
|
}
|
||||||
|
loop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", startCountdowns);
|
||||||
|
} else {
|
||||||
|
startCountdowns();
|
||||||
|
}
|
||||||
|
|
||||||
|
// After an htmx swap, freshly-inserted countdowns need to be started too.
|
||||||
|
document.addEventListener("htmx:afterSwap", startCountdowns);
|
||||||
|
})();
|
||||||
75
static/js/price-chart.js
Normal file
75
static/js/price-chart.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// 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);
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Vertical gradient fill — bright accent at the top, transparent at the
|
||||||
|
// baseline — makes the chart read at a glance instead of as a thin line.
|
||||||
|
var fill = ctx.createLinearGradient(0, 0, 0, canvas.clientHeight || 200);
|
||||||
|
fill.addColorStop(0, 'rgba(0, 228, 164, 0.45)');
|
||||||
|
fill.addColorStop(1, 'rgba(0, 228, 164, 0.00)');
|
||||||
|
|
||||||
|
// Chart.js plugin that paints a soft glow under the line stroke before
|
||||||
|
// the dataset draws. Cheap enough to keep on by default; respects
|
||||||
|
// prefers-reduced-motion only insofar as nothing animates here.
|
||||||
|
var glowPlugin = {
|
||||||
|
id: 'priceLineGlow',
|
||||||
|
beforeDatasetDraw: function (chart) {
|
||||||
|
var c = chart.ctx;
|
||||||
|
c.save();
|
||||||
|
c.shadowColor = 'rgba(0, 228, 164, 0.55)';
|
||||||
|
c.shadowBlur = 12;
|
||||||
|
},
|
||||||
|
afterDatasetDraw: function (chart) { chart.ctx.restore(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Best price',
|
||||||
|
data: data.points,
|
||||||
|
borderColor: '#00e4a4',
|
||||||
|
borderWidth: 2.5,
|
||||||
|
backgroundColor: fill,
|
||||||
|
pointBackgroundColor: '#ffffff',
|
||||||
|
pointBorderColor: '#00e4a4',
|
||||||
|
pointBorderWidth: 1.5,
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: '#a8c0f0' }, grid: { color: 'rgba(255,255,255,0.06)' } },
|
||||||
|
y: { ticks: { color: '#a8c0f0' }, grid: { color: 'rgba(255,255,255,0.06)' } }
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { labels: { color: '#ffffff' } },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(20, 32, 80, 0.95)',
|
||||||
|
borderColor: 'rgba(0, 164, 228, 0.6)',
|
||||||
|
borderWidth: 1,
|
||||||
|
titleColor: '#ffffff',
|
||||||
|
bodyColor: '#a8c0f0',
|
||||||
|
padding: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [glowPlugin]
|
||||||
|
});
|
||||||
|
})();
|
||||||
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
|
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">
|
<div hx-get="/dashboard/refresh" hx-trigger="every 60s" hx-swap="outerHTML">
|
||||||
<h1 class="text-3xl font-semibold mb-6">Dashboard</h1>
|
<h1 class="text-3xl font-semibold mb-6">Dashboard</h1>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
@@ -46,7 +51,7 @@ templ dashboardBody(d DashboardData) {
|
|||||||
<div class="grid md:grid-cols-2 gap-4 mb-6">
|
<div class="grid md:grid-cols-2 gap-4 mb-6">
|
||||||
<div class="v-card p-5">
|
<div class="v-card p-5">
|
||||||
<div class="v-muted text-sm uppercase tracking-wide">Potential Spend</div>
|
<div class="v-muted text-sm uppercase tracking-wide">Potential Spend</div>
|
||||||
<div class="font-mono text-4xl mt-2">{ fmt.Sprintf("$%.2f", d.Stats.PotentialSpend) }</div>
|
<div class="font-mono text-4xl mt-2" data-countup>{ fmt.Sprintf("$%.2f", d.Stats.PotentialSpend) }</div>
|
||||||
<div class="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.PricedItemCount) } items</div>
|
<div class="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.PricedItemCount) } items</div>
|
||||||
if d.Stats.UnpricedCount > 0 {
|
if d.Stats.UnpricedCount > 0 {
|
||||||
<div class="v-muted text-xs mt-1">{ fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount) }</div>
|
<div class="v-muted text-xs mt-1">{ fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount) }</div>
|
||||||
@@ -54,7 +59,7 @@ templ dashboardBody(d DashboardData) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="v-card p-5">
|
<div class="v-card p-5">
|
||||||
<div class="v-muted text-sm uppercase tracking-wide">Money Saved</div>
|
<div class="v-muted text-sm uppercase tracking-wide">Money Saved</div>
|
||||||
<div class="font-mono text-4xl mt-2 v-price-deal">{ fmt.Sprintf("$%.2f", d.Stats.MoneySaved) }</div>
|
<div class="font-mono text-4xl mt-2 v-price-deal" data-countup>{ fmt.Sprintf("$%.2f", d.Stats.MoneySaved) }</div>
|
||||||
<div class="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items</div>
|
<div class="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +108,7 @@ templ dashboardBody(d DashboardData) {
|
|||||||
templ statCard(label, value, sub string) {
|
templ statCard(label, value, sub string) {
|
||||||
<div class="v-card p-4">
|
<div class="v-card p-4">
|
||||||
<div class="v-muted text-xs uppercase tracking-wide">{ label }</div>
|
<div class="v-muted text-xs uppercase tracking-wide">{ label }</div>
|
||||||
<div class="font-mono text-3xl mt-1">{ value }</div>
|
<div class="font-mono text-3xl mt-1" data-countup>{ value }</div>
|
||||||
if sub != "" {
|
if sub != "" {
|
||||||
<div class="v-muted text-xs mt-1">{ sub }</div>
|
<div class="v-muted text-xs mt-1">{ sub }</div>
|
||||||
}
|
}
|
||||||
@@ -111,7 +116,7 @@ templ statCard(label, value, sub string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ Dashboard(d DashboardData) {
|
templ Dashboard(d DashboardData) {
|
||||||
@Layout(d.Page, dashboardBody(d))
|
@Layout(d.Page, DashboardBody(d))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helpers used by multiple templates.
|
// Helpers used by multiple templates.
|
||||||
|
|||||||
@@ -42,7 +42,12 @@ type AlertRow struct {
|
|||||||
FoundAt time.Time
|
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) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
@@ -83,14 +88,14 @@ func dashboardBody(d DashboardData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div><div class=\"grid md:grid-cols-2 gap-4 mb-6\"><div class=\"v-card p-5\"><div class=\"v-muted text-sm uppercase tracking-wide\">Potential Spend</div><div class=\"font-mono text-4xl mt-2\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div><div class=\"grid md:grid-cols-2 gap-4 mb-6\"><div class=\"v-card p-5\"><div class=\"v-muted text-sm uppercase tracking-wide\">Potential Spend</div><div class=\"font-mono text-4xl mt-2\" data-countup>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.PotentialSpend))
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.PotentialSpend))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 49, Col: 87}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 54, Col: 100}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -103,7 +108,7 @@ func dashboardBody(d DashboardData) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.PricedItemCount))
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.PricedItemCount))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -121,7 +126,7 @@ func dashboardBody(d DashboardData) templ.Component {
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount))
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -132,14 +137,14 @@ func dashboardBody(d DashboardData) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"v-card p-5\"><div class=\"v-muted text-sm uppercase tracking-wide\">Money Saved</div><div class=\"font-mono text-4xl mt-2 v-price-deal\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"v-card p-5\"><div class=\"v-muted text-sm uppercase tracking-wide\">Money Saved</div><div class=\"font-mono text-4xl mt-2 v-price-deal\" data-countup>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.MoneySaved))
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.MoneySaved))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 57, Col: 96}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 62, Col: 109}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -152,7 +157,7 @@ func dashboardBody(d DashboardData) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.SavedItemCount))
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.SavedItemCount))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -180,7 +185,7 @@ func dashboardBody(d DashboardData) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 templ.SafeURL
|
var templ_7745c5c3_Var7 templ.SafeURL
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)))
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)))
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -193,7 +198,7 @@ func dashboardBody(d DashboardData) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -206,7 +211,7 @@ func dashboardBody(d DashboardData) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -219,7 +224,7 @@ func dashboardBody(d DashboardData) templ.Component {
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -232,7 +237,7 @@ func dashboardBody(d DashboardData) templ.Component {
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt))
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt))
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -270,7 +275,7 @@ func dashboardBody(d DashboardData) templ.Component {
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(a.ItemName)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(a.ItemName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -283,7 +288,7 @@ func dashboardBody(d DashboardData) templ.Component {
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(a.Price, a.Currency))
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(a.Price, a.Currency))
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -335,20 +340,20 @@ func statCard(label, value, sub string) templ.Component {
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(label)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(label)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><div class=\"font-mono text-3xl mt-1\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><div class=\"font-mono text-3xl mt-1\" data-countup>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(value)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(value)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 106, Col: 46}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 111, Col: 59}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -366,7 +371,7 @@ func statCard(label, value, sub string) templ.Component {
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(sub)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(sub)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -406,7 +411,7 @@ func Dashboard(d DashboardData) templ.Component {
|
|||||||
templ_7745c5c3_Var18 = templ.NopComponent
|
templ_7745c5c3_Var18 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = Layout(d.Page, dashboardBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Layout(d.Page, DashboardBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,43 @@ type ItemFormData struct {
|
|||||||
|
|
||||||
func itemSelected(have, want string) bool { return have == want }
|
func itemSelected(have, want string) bool { return have == want }
|
||||||
|
|
||||||
|
type selectOpt struct {
|
||||||
|
Value string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
// conditionOptions are the eBay-only item-condition filters. Values match the
|
||||||
|
// vocabulary mapped to Browse API condition IDs in internal/ebay.
|
||||||
|
func conditionOptions() []selectOpt {
|
||||||
|
return []selectOpt{
|
||||||
|
{"", "— any —"},
|
||||||
|
{"new", "New"},
|
||||||
|
{"used", "Used"},
|
||||||
|
{"refurbished", "Refurbished"},
|
||||||
|
{"parts", "For parts / not working"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// regionOptions are the eBay-only item-location filters, keyed by ISO 3166-1
|
||||||
|
// alpha-2 country code (the value the Browse API itemLocationCountry filter
|
||||||
|
// expects).
|
||||||
|
func regionOptions() []selectOpt {
|
||||||
|
return []selectOpt{
|
||||||
|
{"", "— any —"},
|
||||||
|
{"US", "United States"},
|
||||||
|
{"GB", "United Kingdom"},
|
||||||
|
{"DE", "Germany"},
|
||||||
|
{"FR", "France"},
|
||||||
|
{"IT", "Italy"},
|
||||||
|
{"ES", "Spain"},
|
||||||
|
{"CA", "Canada"},
|
||||||
|
{"AU", "Australia"},
|
||||||
|
{"JP", "Japan"},
|
||||||
|
{"CN", "China"},
|
||||||
|
{"HK", "Hong Kong"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type marketplaceOpt struct {
|
type marketplaceOpt struct {
|
||||||
Value string
|
Value string
|
||||||
Label string
|
Label string
|
||||||
@@ -226,6 +263,26 @@ templ itemFormInner(d ItemFormData) {
|
|||||||
<div class="v-muted text-xs mt-1">Drop results whose title contains any of these. Case-insensitive substring match.</div>
|
<div class="v-muted text-xs mt-1">Drop results whose title contains any of these. Case-insensitive substring match.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="v-label">Condition</label>
|
||||||
|
<select class="v-select" name="condition">
|
||||||
|
for _, o := range conditionOptions() {
|
||||||
|
<option value={ o.Value } selected?={ itemSelected(d.Item.Condition, o.Value) }>{ o.Label }</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<div class="v-muted text-xs mt-1">eBay marketplaces only. Ignored for Yahoo JP, Mercari, and custom actors.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="v-label">Item Location</label>
|
||||||
|
<select class="v-select" name="region">
|
||||||
|
for _, o := range regionOptions() {
|
||||||
|
<option value={ o.Value } selected?={ itemSelected(d.Item.Region, o.Value) }>{ o.Label }</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<div class="v-muted text-xs mt-1">Restrict to items located in this country. eBay marketplaces only.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="flex items-center gap-2">
|
<label class="flex items-center gap-2">
|
||||||
<input type="checkbox" name="include_out_of_stock" checked?={ d.Item.IncludeOutOfStock } value="1"/>
|
<input type="checkbox" name="include_out_of_stock" checked?={ d.Item.IncludeOutOfStock } value="1"/>
|
||||||
<span>Include out-of-stock results</span>
|
<span>Include out-of-stock results</span>
|
||||||
|
|||||||
@@ -24,6 +24,43 @@ type ItemFormData struct {
|
|||||||
|
|
||||||
func itemSelected(have, want string) bool { return have == want }
|
func itemSelected(have, want string) bool { return have == want }
|
||||||
|
|
||||||
|
type selectOpt struct {
|
||||||
|
Value string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
// conditionOptions are the eBay-only item-condition filters. Values match the
|
||||||
|
// vocabulary mapped to Browse API condition IDs in internal/ebay.
|
||||||
|
func conditionOptions() []selectOpt {
|
||||||
|
return []selectOpt{
|
||||||
|
{"", "— any —"},
|
||||||
|
{"new", "New"},
|
||||||
|
{"used", "Used"},
|
||||||
|
{"refurbished", "Refurbished"},
|
||||||
|
{"parts", "For parts / not working"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// regionOptions are the eBay-only item-location filters, keyed by ISO 3166-1
|
||||||
|
// alpha-2 country code (the value the Browse API itemLocationCountry filter
|
||||||
|
// expects).
|
||||||
|
func regionOptions() []selectOpt {
|
||||||
|
return []selectOpt{
|
||||||
|
{"", "— any —"},
|
||||||
|
{"US", "United States"},
|
||||||
|
{"GB", "United Kingdom"},
|
||||||
|
{"DE", "Germany"},
|
||||||
|
{"FR", "France"},
|
||||||
|
{"IT", "Italy"},
|
||||||
|
{"ES", "Spain"},
|
||||||
|
{"CA", "Canada"},
|
||||||
|
{"AU", "Australia"},
|
||||||
|
{"JP", "Japan"},
|
||||||
|
{"CN", "China"},
|
||||||
|
{"HK", "Hong Kong"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type marketplaceOpt struct {
|
type marketplaceOpt struct {
|
||||||
Value string
|
Value string
|
||||||
Label string
|
Label string
|
||||||
@@ -138,7 +175,7 @@ func itemFormBody(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Name)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 104, Col: 22}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 141, Col: 22}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -167,7 +204,7 @@ func itemFormBody(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(e)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(e)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 113, Col: 13}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 150, Col: 13}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -223,7 +260,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 templ.SafeURL
|
var templ_7745c5c3_Var5 templ.SafeURL
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(formAction(d))
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(formAction(d))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 125, Col: 24}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 162, Col: 24}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -254,7 +291,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.Name)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 139, Col: 58}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 176, Col: 58}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -282,7 +319,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(c)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(c)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 146, Col: 23}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 183, Col: 23}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -305,7 +342,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(c)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(c)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 146, Col: 64}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 183, Col: 64}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -323,7 +360,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(newCategory(d.Item.Category, d.Categories))
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(newCategory(d.Item.Category, d.Categories))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 149, Col: 110}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 186, Col: 110}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -336,7 +373,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.SearchQuery)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.SearchQuery)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 154, Col: 150}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 191, Col: 150}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -349,7 +386,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.URL)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.URL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 159, Col: 55}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 196, Col: 55}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -362,7 +399,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(optFloat(d.Item.TargetPrice))
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(optFloat(d.Item.TargetPrice))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 165, Col: 119}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 202, Col: 119}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -375,7 +412,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.NtfyTopic)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.NtfyTopic)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 170, Col: 69}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 207, Col: 69}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -393,7 +430,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(p)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(p)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 176, Col: 23}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 213, Col: 23}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -416,7 +453,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 176, Col: 80}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 213, Col: 80}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -439,7 +476,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", opt.Minutes))
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", opt.Minutes))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 186, Col: 52}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 223, Col: 52}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -462,7 +499,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 186, Col: 122}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 223, Col: 122}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -485,7 +522,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 195, Col: 64}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 232, Col: 64}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -508,7 +545,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 196, Col: 22}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 233, Col: 22}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -526,7 +563,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(customMarketplacesCSV(d.Item))
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(customMarketplacesCSV(d.Item))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 200, Col: 103}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 237, Col: 103}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -554,7 +591,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(lt)
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(lt)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 212, Col: 24}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 249, Col: 24}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -577,7 +614,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(lt)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(lt)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 212, Col: 82}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 249, Col: 82}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -595,7 +632,7 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(optFloat(d.Item.MinPrice))
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(optFloat(d.Item.MinPrice))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 220, Col: 113}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 257, Col: 113}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -608,92 +645,184 @@ func itemFormInner(d ItemFormData) templ.Component {
|
|||||||
var templ_7745c5c3_Var24 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.ExcludeKeywords)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.ExcludeKeywords)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 225, Col: 137}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 262, Col: 137}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</textarea><div class=\"v-muted text-xs mt-1\">Drop results whose title contains any of these. Case-insensitive substring match.</div></div></div><label class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"include_out_of_stock\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</textarea><div class=\"v-muted text-xs mt-1\">Drop results whose title contains any of these. Case-insensitive substring match.</div></div></div><div class=\"grid md:grid-cols-2 gap-4\"><div><label class=\"v-label\">Condition</label> <select class=\"v-select\" name=\"condition\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, o := range conditionOptions() {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<option value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var25 string
|
||||||
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(o.Value)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 271, Col: 29}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if itemSelected(d.Item.Condition, o.Value) {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var26 string
|
||||||
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(o.Label)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 271, Col: 95}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</select><div class=\"v-muted text-xs mt-1\">eBay marketplaces only. Ignored for Yahoo JP, Mercari, and custom actors.</div></div><div><label class=\"v-label\">Item Location</label> <select class=\"v-select\" name=\"region\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, o := range regionOptions() {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<option value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var27 string
|
||||||
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(o.Value)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 280, Col: 29}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if itemSelected(d.Item.Region, o.Value) {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var28 string
|
||||||
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(o.Label)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 280, Col: 92}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</select><div class=\"v-muted text-xs mt-1\">Restrict to items located in this country. eBay marketplaces only.</div></div></div><label class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"include_out_of_stock\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if d.Item.IncludeOutOfStock {
|
if d.Item.IncludeOutOfStock {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " checked")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " checked")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " value=\"1\"> <span>Include out-of-stock results</span></label> <details class=\"v-card-flat p-4\"><summary class=\"cursor-pointer font-semibold\">Advanced</summary><div class=\"v-muted text-sm mt-2\">Leave blank to use the configured default for the selected marketplace.</div><div class=\"grid md:grid-cols-2 gap-4 mt-3\"><div><label class=\"v-label\">Active Listings Actor</label> <input class=\"v-input\" name=\"actor_active\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " value=\"1\"> <span>Include out-of-stock results</span></label> <details class=\"v-card-flat p-4\"><summary class=\"cursor-pointer font-semibold\">Advanced</summary><div class=\"v-muted text-sm mt-2\">Leave blank to use the configured default for the selected marketplace.</div><div class=\"grid md:grid-cols-2 gap-4 mt-3\"><div><label class=\"v-label\">Active Listings Actor</label> <input class=\"v-input\" name=\"actor_active\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var25 string
|
var templ_7745c5c3_Var29 string
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorActive)
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorActive)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 239, Col: 74}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 296, Col: 74}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" placeholder=\"from config\"></div><div><label class=\"v-label\">Sold Listings Actor</label> <input class=\"v-input\" name=\"actor_sold\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" placeholder=\"from config\"></div><div><label class=\"v-label\">Sold Listings Actor</label> <input class=\"v-input\" name=\"actor_sold\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var26 string
|
var templ_7745c5c3_Var30 string
|
||||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorSold)
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorSold)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 243, Col: 70}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 300, Col: 70}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" placeholder=\"from config\"></div><div><label class=\"v-label\">Price Comparison Actor</label> <input class=\"v-input\" name=\"actor_price_compare\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" placeholder=\"from config\"></div><div><label class=\"v-label\">Price Comparison Actor</label> <input class=\"v-input\" name=\"actor_price_compare\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var27 string
|
var templ_7745c5c3_Var31 string
|
||||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorPriceCompare)
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Item.ActorPriceCompare)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 247, Col: 87}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_form.templ`, Line: 304, Col: 87}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" placeholder=\"from config\"></div><label class=\"flex items-center gap-2 mt-6\"><input type=\"checkbox\" name=\"use_price_comparison\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" placeholder=\"from config\"></div><label class=\"flex items-center gap-2 mt-6\"><input type=\"checkbox\" name=\"use_price_comparison\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if d.Item.UsePriceComparison {
|
if d.Item.UsePriceComparison {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " checked")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " checked")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " value=\"1\"> <span>Use price comparison actor in addition to active listings</span></label></div></details><div class=\"flex items-center gap-3 pt-2\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, " value=\"1\"> <span>Use price comparison actor in addition to active listings</span></label></div></details><div class=\"flex items-center gap-3 pt-2\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if d.IsEdit {
|
if d.IsEdit {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<button class=\"v-btn\" type=\"submit\">Save</button> <a class=\"v-btn-ghost\" href=\"/items\">Cancel</a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<button class=\"v-btn\" type=\"submit\">Save</button> <a class=\"v-btn-ghost\" href=\"/items\">Cancel</a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<button class=\"v-btn\" type=\"submit\">Preview</button> <a class=\"v-btn-ghost\" href=\"/items\">Cancel</a> <span id=\"preview-loading\" class=\"htmx-indicator v-muted text-sm flex items-center gap-2\"><span class=\"v-spinner\"></span> Running preview… apify runs can take 30–60s.</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<button class=\"v-btn\" type=\"submit\">Preview</button> <a class=\"v-btn-ghost\" href=\"/items\">Cancel</a> <span id=\"preview-loading\" class=\"htmx-indicator v-muted text-sm flex items-center gap-2\"><span class=\"v-spinner\"></span> Running preview… apify runs can take 30–60s.</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div></form>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</div></form>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if !d.IsEdit {
|
if !d.IsEdit {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div id=\"preview-target\" class=\"mt-6\"></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div id=\"preview-target\" class=\"mt-6\"></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -744,9 +873,9 @@ func ItemForm(d ItemFormData) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var28 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var32 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var28 == nil {
|
if templ_7745c5c3_Var32 == nil {
|
||||||
templ_7745c5c3_Var28 = templ.NopComponent
|
templ_7745c5c3_Var32 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = Layout(d.Page, itemFormBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Layout(d.Page, itemFormBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ type FormValues struct {
|
|||||||
IncludeOutOfStock bool
|
IncludeOutOfStock bool
|
||||||
Marketplaces []string
|
Marketplaces []string
|
||||||
ListingType string
|
ListingType string
|
||||||
|
Condition string
|
||||||
|
Region string
|
||||||
ActorActive string
|
ActorActive string
|
||||||
ActorSold string
|
ActorSold string
|
||||||
ActorPriceCompare string
|
ActorPriceCompare string
|
||||||
@@ -136,6 +138,8 @@ templ confirmForm(d PreviewData) {
|
|||||||
@hidden("marketplace", m)
|
@hidden("marketplace", m)
|
||||||
}
|
}
|
||||||
@hidden("listing_type", d.Form.ListingType)
|
@hidden("listing_type", d.Form.ListingType)
|
||||||
|
@hidden("condition", d.Form.Condition)
|
||||||
|
@hidden("region", d.Form.Region)
|
||||||
@hidden("actor_active", d.Form.ActorActive)
|
@hidden("actor_active", d.Form.ActorActive)
|
||||||
@hidden("actor_sold", d.Form.ActorSold)
|
@hidden("actor_sold", d.Form.ActorSold)
|
||||||
@hidden("actor_price_compare", d.Form.ActorPriceCompare)
|
@hidden("actor_price_compare", d.Form.ActorPriceCompare)
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ type FormValues struct {
|
|||||||
IncludeOutOfStock bool
|
IncludeOutOfStock bool
|
||||||
Marketplaces []string
|
Marketplaces []string
|
||||||
ListingType string
|
ListingType string
|
||||||
|
Condition string
|
||||||
|
Region string
|
||||||
ActorActive string
|
ActorActive string
|
||||||
ActorSold string
|
ActorSold string
|
||||||
ActorPriceCompare string
|
ActorPriceCompare string
|
||||||
@@ -78,7 +80,7 @@ func ItemPreview(d PreviewData) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 48, Col: 17}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 50, Col: 17}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -101,7 +103,7 @@ func ItemPreview(d PreviewData) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Found %d results for '%s'", len(d.Results), d.Form.SearchQuery))
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Found %d results for '%s'", len(d.Results), d.Form.SearchQuery))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 60, Col: 83}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 62, Col: 83}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -144,7 +146,7 @@ func ItemPreview(d PreviewData) templ.Component {
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ImageURL)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ImageURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 75, Col: 31}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 77, Col: 31}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -162,7 +164,7 @@ func ItemPreview(d PreviewData) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 templ.SafeURL
|
var templ_7745c5c3_Var5 templ.SafeURL
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL))
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 78, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 80, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -175,7 +177,7 @@ func ItemPreview(d PreviewData) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 78, Col: 99}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 80, Col: 99}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -188,7 +190,7 @@ func ItemPreview(d PreviewData) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(r.Store)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(r.Store)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 79, Col: 48}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 81, Col: 48}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -201,7 +203,7 @@ func ItemPreview(d PreviewData) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(r.Price, r.Currency))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(r.Price, r.Currency))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 81, Col: 64}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 83, Col: 64}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -225,7 +227,7 @@ func ItemPreview(d PreviewData) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("and %d more", len(d.Results)-6))
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("and %d more", len(d.Results)-6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 87, Col: 86}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 89, Col: 86}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -248,7 +250,7 @@ func ItemPreview(d PreviewData) templ.Component {
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Prices range from %s to %s across %d stores", fmtNumber(d.MinPrice, d.Currency), fmtNumber(d.MaxPrice, d.Currency), d.StoreCount))
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Prices range from %s to %s across %d stores", fmtNumber(d.MinPrice, d.Currency), fmtNumber(d.MaxPrice, d.Currency), d.StoreCount))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 93, Col: 148}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 95, Col: 148}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -301,7 +303,7 @@ func previewBest(d PreviewData) templ.Component {
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Results[d.BestIndex].ImageURL)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Results[d.BestIndex].ImageURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 104, Col: 46}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 106, Col: 46}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -324,7 +326,7 @@ func previewBest(d PreviewData) templ.Component {
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(d.Results[d.BestIndex].Price, d.Currency))
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(d.Results[d.BestIndex].Price, d.Currency))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 110, Col: 94}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 112, Col: 94}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -337,7 +339,7 @@ func previewBest(d PreviewData) templ.Component {
|
|||||||
var templ_7745c5c3_Var14 templ.SafeURL
|
var templ_7745c5c3_Var14 templ.SafeURL
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(d.Results[d.BestIndex].URL))
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(d.Results[d.BestIndex].URL))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 111, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 113, Col: 77}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -350,7 +352,7 @@ func previewBest(d PreviewData) templ.Component {
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].Title)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 111, Col: 141}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 113, Col: 141}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -363,7 +365,7 @@ func previewBest(d PreviewData) templ.Component {
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].Store)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].Store)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 112, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 114, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -381,7 +383,7 @@ func previewBest(d PreviewData) templ.Component {
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].MatchedQuery)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].MatchedQuery)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 114, Col: 81}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 116, Col: 81}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -429,7 +431,7 @@ func confirmForm(d PreviewData) templ.Component {
|
|||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 123, Col: 60}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 125, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -493,6 +495,14 @@ func confirmForm(d PreviewData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
templ_7745c5c3_Err = hidden("condition", d.Form.Condition).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = hidden("region", d.Form.Region).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
templ_7745c5c3_Err = hidden("actor_active", d.Form.ActorActive).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = hidden("actor_active", d.Form.ActorActive).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
@@ -555,7 +565,7 @@ func hidden(name, value string) templ.Component {
|
|||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 149, Col: 33}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 153, Col: 33}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -568,7 +578,7 @@ func hidden(name, value string) templ.Component {
|
|||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(value)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(value)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 149, Col: 49}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 153, Col: 49}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -611,7 +621,7 @@ func hiddenBool(name string, value bool) templ.Component {
|
|||||||
var templ_7745c5c3_Var24 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 154, Col: 34}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 158, Col: 34}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package templates
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"veola/internal/models"
|
"veola/internal/models"
|
||||||
)
|
)
|
||||||
@@ -11,6 +13,85 @@ type ItemsData struct {
|
|||||||
Items []models.Item
|
Items []models.Item
|
||||||
Categories []string
|
Categories []string
|
||||||
SelectedCategory string
|
SelectedCategory string
|
||||||
|
// PriceHistory holds the last ~20 points per item id, ascending in time.
|
||||||
|
// Nil/missing entries render an empty sparkline cell.
|
||||||
|
PriceHistory map[int64][]models.PricePoint
|
||||||
|
}
|
||||||
|
|
||||||
|
// sparklinePoints turns a price-history slice into an SVG polyline "points"
|
||||||
|
// attribute, normalized to a 80x24 viewBox with 2px padding so endpoints
|
||||||
|
// don't clip the stroke. Returns "" when there isn't enough history to draw.
|
||||||
|
func sparklinePoints(history []models.PricePoint) string {
|
||||||
|
if len(history) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
const w, h, pad = 80.0, 24.0, 2.0
|
||||||
|
minP, maxP := math.Inf(1), math.Inf(-1)
|
||||||
|
for _, p := range history {
|
||||||
|
if p.Price < minP {
|
||||||
|
minP = p.Price
|
||||||
|
}
|
||||||
|
if p.Price > maxP {
|
||||||
|
maxP = p.Price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span := maxP - minP
|
||||||
|
if span == 0 {
|
||||||
|
// All-equal series: draw a flat line through the middle.
|
||||||
|
span = 1
|
||||||
|
}
|
||||||
|
step := (w - 2*pad) / float64(len(history)-1)
|
||||||
|
parts := make([]string, len(history))
|
||||||
|
for i, p := range history {
|
||||||
|
x := pad + float64(i)*step
|
||||||
|
y := h - pad - ((p.Price-minP)/span)*(h-2*pad)
|
||||||
|
parts[i] = fmt.Sprintf("%.1f,%.1f", x, y)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// sparklineTrendClass compares the last point to the average of the prior
|
||||||
|
// points and returns a CSS class so the sparkline tints green (price down),
|
||||||
|
// red (up), or neutral. Threshold is ±3% to ignore tiny wobbles.
|
||||||
|
func sparklineTrendClass(history []models.PricePoint) string {
|
||||||
|
if len(history) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
last := history[len(history)-1].Price
|
||||||
|
var sum float64
|
||||||
|
for _, p := range history[:len(history)-1] {
|
||||||
|
sum += p.Price
|
||||||
|
}
|
||||||
|
avg := sum / float64(len(history)-1)
|
||||||
|
if avg == 0 {
|
||||||
|
return "v-spark-flat"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case last <= avg*0.97:
|
||||||
|
return "v-spark-down"
|
||||||
|
case last >= avg*1.03:
|
||||||
|
return "v-spark-up"
|
||||||
|
}
|
||||||
|
return "v-spark-flat"
|
||||||
|
}
|
||||||
|
|
||||||
|
// trendArrow returns the unicode arrow + a CSS class describing the direction.
|
||||||
|
// Same threshold logic as the sparkline. Empty when there's no trend signal.
|
||||||
|
func trendArrow(history []models.PricePoint) (glyph, class string) {
|
||||||
|
switch sparklineTrendClass(history) {
|
||||||
|
case "v-spark-down":
|
||||||
|
return "↓", "v-trend-down"
|
||||||
|
case "v-spark-up":
|
||||||
|
return "↑", "v-trend-up"
|
||||||
|
case "v-spark-flat":
|
||||||
|
return "→", "v-trend-flat"
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDeal is the gate for the mascot "deal" moment on a row.
|
||||||
|
func isDeal(it models.Item) bool {
|
||||||
|
return it.BestPrice != nil && it.TargetPrice != nil && *it.BestPrice <= *it.TargetPrice
|
||||||
}
|
}
|
||||||
|
|
||||||
templ itemsBody(d ItemsData) {
|
templ itemsBody(d ItemsData) {
|
||||||
@@ -41,6 +122,7 @@ templ itemsBody(d ItemsData) {
|
|||||||
<th>Category</th>
|
<th>Category</th>
|
||||||
<th>Target</th>
|
<th>Target</th>
|
||||||
<th>Best Price</th>
|
<th>Best Price</th>
|
||||||
|
<th>Trend</th>
|
||||||
<th>Last Polled</th>
|
<th>Last Polled</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
@@ -48,7 +130,7 @@ templ itemsBody(d ItemsData) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="items-tbody">
|
<tbody id="items-tbody">
|
||||||
for _, it := range d.Items {
|
for _, it := range d.Items {
|
||||||
@itemRow(it, d.CSRFToken)
|
@itemRow(it, d.CSRFToken, d.PriceHistory[it.ID])
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -60,7 +142,7 @@ templ itemsBody(d ItemsData) {
|
|||||||
templ itemsEmpty() {
|
templ itemsEmpty() {
|
||||||
<div class="v-card p-8 flex flex-col md:flex-row items-center gap-6">
|
<div class="v-card p-8 flex flex-col md:flex-row items-center gap-6">
|
||||||
<div class="v-veola-portrait w-48 shrink-0">
|
<div class="v-veola-portrait w-48 shrink-0">
|
||||||
<img src="/static/img/veola.webp" alt="Veola"/>
|
<img src="/static/img/veola.avif" alt="Veola"/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold mb-2">Nothing on the watchlist.</h2>
|
<h2 class="text-xl font-semibold mb-2">Nothing on the watchlist.</h2>
|
||||||
@@ -70,10 +152,15 @@ templ itemsEmpty() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ itemRow(it models.Item, csrf string) {
|
templ itemRow(it models.Item, csrf string, history []models.PricePoint) {
|
||||||
<tr id={ fmt.Sprintf("item-row-%d", it.ID) }>
|
<tr id={ fmt.Sprintf("item-row-%d", it.ID) }>
|
||||||
<td>
|
<td>
|
||||||
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID)) }>{ it.Name }</a>
|
<div class="flex items-center gap-2">
|
||||||
|
if isDeal(it) {
|
||||||
|
<img class="v-deal-mascot" src="/static/img/veola.avif" alt="" title={ fmt.Sprintf("Deal! Best %s ≤ target %s", fmtPrice(it.BestPrice, it.BestPriceCurrency), fmtPrice(it.TargetPrice, "USD")) }/>
|
||||||
|
}
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID)) }>{ it.Name }</a>
|
||||||
|
</div>
|
||||||
if it.LastPollError != "" {
|
if it.LastPollError != "" {
|
||||||
<button class="v-pill v-pill-error ml-2" hx-get={ fmt.Sprintf("/items/%d/error", it.ID) } hx-target={ fmt.Sprintf("#item-error-%d", it.ID) } hx-swap="innerHTML">!</button>
|
<button class="v-pill v-pill-error ml-2" hx-get={ fmt.Sprintf("/items/%d/error", it.ID) } hx-target={ fmt.Sprintf("#item-error-%d", it.ID) } hx-swap="innerHTML">!</button>
|
||||||
<div id={ fmt.Sprintf("item-error-%d", it.ID) } class="v-error-text mt-1"></div>
|
<div id={ fmt.Sprintf("item-error-%d", it.ID) } class="v-error-text mt-1"></div>
|
||||||
@@ -89,7 +176,12 @@ templ itemRow(it models.Item, csrf string) {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
if it.BestPrice != nil {
|
if it.BestPrice != nil {
|
||||||
<div class={ "font-mono text-lg", priceClass(it.BestPrice, it.TargetPrice) }>{ fmtPrice(it.BestPrice, "USD") }</div>
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class={ "font-mono text-lg", priceClass(it.BestPrice, it.TargetPrice) }>{ fmtPrice(it.BestPrice, it.BestPriceCurrency) }</span>
|
||||||
|
if glyph, cls := trendArrow(history); glyph != "" {
|
||||||
|
<span class={ "v-trend", cls }>{ glyph }</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
if it.BestPriceURL != "" {
|
if it.BestPriceURL != "" {
|
||||||
<a class="text-xs" href={ templ.SafeURL(it.BestPriceURL) } target="_blank" rel="noopener">{ it.BestPriceStore }</a>
|
<a class="text-xs" href={ templ.SafeURL(it.BestPriceURL) } target="_blank" rel="noopener">{ it.BestPriceStore }</a>
|
||||||
} else if it.BestPriceStore != "" {
|
} else if it.BestPriceStore != "" {
|
||||||
@@ -99,6 +191,15 @@ templ itemRow(it models.Item, csrf string) {
|
|||||||
<span class="v-muted">not yet</span>
|
<span class="v-muted">not yet</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
if pts := sparklinePoints(history); pts != "" {
|
||||||
|
<svg class={ "v-sparkline", sparklineTrendClass(history) } viewBox="0 0 80 24" width="80" height="24" aria-hidden="true">
|
||||||
|
<polyline points={ pts } fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
} else {
|
||||||
|
<span class="v-muted text-xs">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td class="v-muted text-sm">
|
<td class="v-muted text-sm">
|
||||||
if it.LastPolledAt != nil {
|
if it.LastPolledAt != nil {
|
||||||
<span title={ it.LastPolledAt.Format("2006-01-02 15:04:05") }>{ humanTime(*it.LastPolledAt) }</span>
|
<span title={ it.LastPolledAt.Format("2006-01-02 15:04:05") }>{ humanTime(*it.LastPolledAt) }</span>
|
||||||
@@ -124,9 +225,12 @@ templ itemRow(it models.Item, csrf string) {
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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 }/>
|
<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>
|
</form>
|
||||||
<a class="v-btn-ghost text-sm" href={ templ.SafeURL(fmt.Sprintf("/items/%d/edit", it.ID)) }>Edit</a>
|
<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?">
|
<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?">
|
||||||
@@ -151,9 +255,11 @@ templ Items(d ItemsData) {
|
|||||||
@Layout(d.Page, itemsBody(d))
|
@Layout(d.Page, itemsBody(d))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ItemRow renders a single row partial, used by HTMX endpoints.
|
// ItemRow renders a single row partial, used by HTMX endpoints. Callers
|
||||||
templ ItemRow(it models.Item, csrf string) {
|
// that don't have history cheaply on hand pass nil; the sparkline cell
|
||||||
@itemRow(it, csrf)
|
// degrades to an em-dash placeholder.
|
||||||
|
templ ItemRow(it models.Item, csrf string, history []models.PricePoint) {
|
||||||
|
@itemRow(it, csrf, history)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmptyRow lets a delete handler return a row replacement that vanishes.
|
// EmptyRow lets a delete handler return a row replacement that vanishes.
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import templruntime "github.com/a-h/templ/runtime"
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"veola/internal/models"
|
"veola/internal/models"
|
||||||
)
|
)
|
||||||
@@ -19,6 +21,85 @@ type ItemsData struct {
|
|||||||
Items []models.Item
|
Items []models.Item
|
||||||
Categories []string
|
Categories []string
|
||||||
SelectedCategory string
|
SelectedCategory string
|
||||||
|
// PriceHistory holds the last ~20 points per item id, ascending in time.
|
||||||
|
// Nil/missing entries render an empty sparkline cell.
|
||||||
|
PriceHistory map[int64][]models.PricePoint
|
||||||
|
}
|
||||||
|
|
||||||
|
// sparklinePoints turns a price-history slice into an SVG polyline "points"
|
||||||
|
// attribute, normalized to a 80x24 viewBox with 2px padding so endpoints
|
||||||
|
// don't clip the stroke. Returns "" when there isn't enough history to draw.
|
||||||
|
func sparklinePoints(history []models.PricePoint) string {
|
||||||
|
if len(history) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
const w, h, pad = 80.0, 24.0, 2.0
|
||||||
|
minP, maxP := math.Inf(1), math.Inf(-1)
|
||||||
|
for _, p := range history {
|
||||||
|
if p.Price < minP {
|
||||||
|
minP = p.Price
|
||||||
|
}
|
||||||
|
if p.Price > maxP {
|
||||||
|
maxP = p.Price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span := maxP - minP
|
||||||
|
if span == 0 {
|
||||||
|
// All-equal series: draw a flat line through the middle.
|
||||||
|
span = 1
|
||||||
|
}
|
||||||
|
step := (w - 2*pad) / float64(len(history)-1)
|
||||||
|
parts := make([]string, len(history))
|
||||||
|
for i, p := range history {
|
||||||
|
x := pad + float64(i)*step
|
||||||
|
y := h - pad - ((p.Price-minP)/span)*(h-2*pad)
|
||||||
|
parts[i] = fmt.Sprintf("%.1f,%.1f", x, y)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// sparklineTrendClass compares the last point to the average of the prior
|
||||||
|
// points and returns a CSS class so the sparkline tints green (price down),
|
||||||
|
// red (up), or neutral. Threshold is ±3% to ignore tiny wobbles.
|
||||||
|
func sparklineTrendClass(history []models.PricePoint) string {
|
||||||
|
if len(history) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
last := history[len(history)-1].Price
|
||||||
|
var sum float64
|
||||||
|
for _, p := range history[:len(history)-1] {
|
||||||
|
sum += p.Price
|
||||||
|
}
|
||||||
|
avg := sum / float64(len(history)-1)
|
||||||
|
if avg == 0 {
|
||||||
|
return "v-spark-flat"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case last <= avg*0.97:
|
||||||
|
return "v-spark-down"
|
||||||
|
case last >= avg*1.03:
|
||||||
|
return "v-spark-up"
|
||||||
|
}
|
||||||
|
return "v-spark-flat"
|
||||||
|
}
|
||||||
|
|
||||||
|
// trendArrow returns the unicode arrow + a CSS class describing the direction.
|
||||||
|
// Same threshold logic as the sparkline. Empty when there's no trend signal.
|
||||||
|
func trendArrow(history []models.PricePoint) (glyph, class string) {
|
||||||
|
switch sparklineTrendClass(history) {
|
||||||
|
case "v-spark-down":
|
||||||
|
return "↓", "v-trend-down"
|
||||||
|
case "v-spark-up":
|
||||||
|
return "↑", "v-trend-up"
|
||||||
|
case "v-spark-flat":
|
||||||
|
return "→", "v-trend-flat"
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDeal is the gate for the mascot "deal" moment on a row.
|
||||||
|
func isDeal(it models.Item) bool {
|
||||||
|
return it.BestPrice != nil && it.TargetPrice != nil && *it.BestPrice <= *it.TargetPrice
|
||||||
}
|
}
|
||||||
|
|
||||||
func itemsBody(d ItemsData) templ.Component {
|
func itemsBody(d ItemsData) templ.Component {
|
||||||
@@ -59,7 +140,7 @@ func itemsBody(d ItemsData) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(c)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(c)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 28, Col: 23}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 109, Col: 23}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -82,7 +163,7 @@ func itemsBody(d ItemsData) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(c)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(c)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 28, Col: 67}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 109, Col: 67}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -104,12 +185,12 @@ func itemsBody(d ItemsData) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"v-card p-0 overflow-hidden\"><table class=\"v-table\"><thead><tr><th>Name</th><th>Category</th><th>Target</th><th>Best Price</th><th>Last Polled</th><th>Status</th><th></th></tr></thead> <tbody id=\"items-tbody\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"v-card p-0 overflow-hidden\"><table class=\"v-table\"><thead><tr><th>Name</th><th>Category</th><th>Target</th><th>Best Price</th><th>Trend</th><th>Last Polled</th><th>Status</th><th></th></tr></thead> <tbody id=\"items-tbody\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, it := range d.Items {
|
for _, it := range d.Items {
|
||||||
templ_7745c5c3_Err = itemRow(it, d.CSRFToken).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = itemRow(it, d.CSRFToken, d.PriceHistory[it.ID]).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -148,7 +229,7 @@ func itemsEmpty() templ.Component {
|
|||||||
templ_7745c5c3_Var4 = templ.NopComponent
|
templ_7745c5c3_Var4 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"v-card p-8 flex flex-col md:flex-row items-center gap-6\"><div class=\"v-veola-portrait w-48 shrink-0\"><img src=\"/static/img/veola.webp\" alt=\"Veola\"></div><div><h2 class=\"text-xl font-semibold mb-2\">Nothing on the watchlist.</h2><p class=\"v-muted mb-4\">Add an item and Veola will keep an eye on it.</p><a class=\"v-btn\" href=\"/items/new\">Add the first item</a></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"v-card p-8 flex flex-col md:flex-row items-center gap-6\"><div class=\"v-veola-portrait w-48 shrink-0\"><img src=\"/static/img/veola.avif\" alt=\"Veola\"></div><div><h2 class=\"text-xl font-semibold mb-2\">Nothing on the watchlist.</h2><p class=\"v-muted mb-4\">Add an item and Veola will keep an eye on it.</p><a class=\"v-btn\" href=\"/items/new\">Add the first item</a></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -156,7 +237,7 @@ func itemsEmpty() templ.Component {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func itemRow(it models.Item, csrf string) templ.Component {
|
func itemRow(it models.Item, csrf string, history []models.PricePoint) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
@@ -184,418 +265,532 @@ func itemRow(it models.Item, csrf string) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("item-row-%d", it.ID))
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("item-row-%d", it.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 74, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 156, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"><td><a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"><td><div class=\"flex items-center gap-2\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 templ.SafeURL
|
if isDeal(it) {
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID)))
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<img class=\"v-deal-mascot\" src=\"/static/img/veola.avif\" alt=\"\" title=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 76, Col: 67}
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("Deal! Best %s ≤ target %s", fmtPrice(it.BestPrice, it.BestPriceCurrency), fmtPrice(it.TargetPrice, "USD")))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 160, Col: 197}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\">")
|
var templ_7745c5c3_Var8 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", it.ID)))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 162, Col: 68}
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var8 string
|
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(it.Name)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 76, Col: 79}
|
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</a> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(it.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 162, Col: 80}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if it.LastPollError != "" {
|
if it.LastPollError != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<button class=\"v-pill v-pill-error ml-2\" hx-get=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<button class=\"v-pill v-pill-error ml-2\" hx-get=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var9 string
|
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/error", it.ID))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 78, Col: 91}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" hx-target=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-error-%d", it.ID))
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/error", it.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 78, Col: 142}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 165, Col: 91}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" hx-swap=\"innerHTML\">!</button><div id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" hx-target=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("item-error-%d", it.ID))
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-error-%d", it.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 79, Col: 49}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 165, Col: 142}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" class=\"v-error-text mt-1\"></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" hx-swap=\"innerHTML\">!</button><div id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("item-error-%d", it.ID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 166, Col: 49}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" class=\"v-error-text mt-1\"></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</td><td class=\"v-muted\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td><td class=\"v-muted\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(it.Category)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(it.Category)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 82, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 169, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</td><td class=\"font-mono\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</td><td class=\"font-mono\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if it.TargetPrice != nil {
|
if it.TargetPrice != nil {
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.TargetPrice, "USD"))
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.TargetPrice, "USD"))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 85, Col: 37}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 172, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"v-muted\">—</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span class=\"v-muted\">—</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td><td>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</td><td>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if it.BestPrice != nil {
|
if it.BestPrice != nil {
|
||||||
var templ_7745c5c3_Var14 = []any{"font-mono text-lg", priceClass(it.BestPrice, it.TargetPrice)}
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"flex items-center gap-1.5\">")
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"")
|
var templ_7745c5c3_Var15 = []any{"font-mono text-lg", priceClass(it.BestPrice, it.TargetPrice)}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var15 string
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span class=\"")
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var14).String())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 1, Col: 0}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.BestPrice, "USD"))
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var15).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 92, Col: 112}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if it.BestPriceURL != "" {
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<a class=\"text-xs\" href=\"")
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.BestPrice, it.BestPriceCurrency))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 180, Col: 129}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if glyph, cls := trendArrow(history); glyph != "" {
|
||||||
|
var templ_7745c5c3_Var18 = []any{"v-trend", cls}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var17 templ.SafeURL
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span class=\"")
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(it.BestPriceURL))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 94, Col: 61}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" target=\"_blank\" rel=\"noopener\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var18 string
|
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(it.BestPriceStore)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 94, Col: 114}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</a>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else if it.BestPriceStore != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<span class=\"text-xs v-muted\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(it.BestPriceStore)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var18).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 96, Col: 54}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var20 string
|
||||||
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(glyph)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 182, Col: 44}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if it.BestPriceURL != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<a class=\"text-xs\" href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var21 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(it.BestPriceURL))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 186, Col: 61}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" target=\"_blank\" rel=\"noopener\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var22 string
|
||||||
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(it.BestPriceStore)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 186, Col: 114}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</a>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else if it.BestPriceStore != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span class=\"text-xs v-muted\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var23 string
|
||||||
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(it.BestPriceStore)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 188, Col: 54}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"v-muted\">not yet</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<span class=\"v-muted\">not yet</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</td><td class=\"v-muted text-sm\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</td><td>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if pts := sparklinePoints(history); pts != "" {
|
||||||
|
var templ_7745c5c3_Var24 = []any{"v-sparkline", sparklineTrendClass(history)}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var24...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<svg class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var25 string
|
||||||
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var24).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" viewBox=\"0 0 80 24\" width=\"80\" height=\"24\" aria-hidden=\"true\"><polyline points=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var26 string
|
||||||
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(pts)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 197, Col: 27}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"></polyline></svg>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<span class=\"v-muted text-xs\">—</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</td><td class=\"v-muted text-sm\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if it.LastPolledAt != nil {
|
if it.LastPolledAt != nil {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span title=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<span title=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var27 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(it.LastPolledAt.Format("2006-01-02 15:04:05"))
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(it.LastPolledAt.Format("2006-01-02 15:04:05"))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 104, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 205, Col: 63}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var28 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(*it.LastPolledAt))
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(*it.LastPolledAt))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 104, Col: 95}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 205, Col: 95}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "—")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "—")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</td><td>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</td><td>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if it.Active {
|
if it.Active {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<span class=\"v-pill v-pill-active\">active</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<span class=\"v-pill v-pill-active\">active</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<span class=\"v-pill v-pill-paused\">paused</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span class=\"v-pill v-pill-paused\">paused</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</td><td class=\"text-right whitespace-nowrap\"><form class=\"inline\" hx-post=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</td><td class=\"text-right whitespace-nowrap\"><form class=\"inline\" hx-post=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var22 string
|
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/toggle", it.ID))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 117, Col: 72}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" hx-target=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var23 string
|
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-row-%d", it.ID))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 117, Col: 121}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var24 string
|
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 118, Col: 55}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\"> <button class=\"v-btn-ghost text-sm\" type=\"submit\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if it.Active {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "Pause")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "Resume")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</button></form><form class=\"inline\" hx-post=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var25 string
|
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/run", it.ID))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 127, Col: 69}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" hx-target=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var26 string
|
|
||||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-row-%d", it.ID))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 127, Col: 118}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var27 string
|
|
||||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 128, Col: 55}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\"> <button class=\"v-btn-ghost text-sm\" type=\"submit\">Run Now</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}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\">Edit</a><form class=\"inline\" hx-post=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var29 string
|
var templ_7745c5c3_Var29 string
|
||||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/delete", it.ID))
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/toggle", it.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 132, Col: 72}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 218, Col: 72}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" hx-target=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" hx-target=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var30 string
|
var templ_7745c5c3_Var30 string
|
||||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-row-%d", it.ID))
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-row-%d", it.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 132, Col: 121}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 218, Col: 121}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" hx-swap=\"outerHTML\" hx-confirm=\"Delete this item?\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var31 string
|
var templ_7745c5c3_Var31 string
|
||||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 133, Col: 55}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 219, Col: 55}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\"> <button class=\"v-btn-ghost text-sm\" type=\"submit\">Delete</button></form></td></tr>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"> <button class=\"v-btn-ghost text-sm\" type=\"submit\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if it.Active {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "Pause")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "Resume")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</button></form><form class=\"inline\" hx-post=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var32 string
|
||||||
|
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/run", it.ID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 228, Col: 69}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" hx-target=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var33 string
|
||||||
|
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-row-%d", it.ID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 228, Col: 118}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" hx-swap=\"outerHTML\" hx-disabled-elt=\"find button\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var34 string
|
||||||
|
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 229, Col: 55}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\"> <button class=\"v-btn-ghost text-sm\" type=\"submit\">Run Now <span class=\"v-spinner htmx-indicator ml-1\"></span></button></form><a class=\"v-btn-ghost text-sm\" href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var35 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/edit", it.ID)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 235, Col: 92}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\">Edit</a><form class=\"inline\" hx-post=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var36 string
|
||||||
|
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/items/%d/delete", it.ID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 236, Col: 72}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" hx-target=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var37 string
|
||||||
|
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("#item-row-%d", it.ID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 236, Col: 121}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" hx-swap=\"outerHTML\" hx-confirm=\"Delete this item?\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var38 string
|
||||||
|
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(csrf)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 237, Col: 55}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\"> <button class=\"v-btn-ghost text-sm\" type=\"submit\">Delete</button></form></td></tr>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -629,9 +824,9 @@ func Items(d ItemsData) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var32 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var39 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var32 == nil {
|
if templ_7745c5c3_Var39 == nil {
|
||||||
templ_7745c5c3_Var32 = templ.NopComponent
|
templ_7745c5c3_Var39 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = Layout(d.Page, itemsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Layout(d.Page, itemsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
@@ -642,8 +837,10 @@ func Items(d ItemsData) templ.Component {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ItemRow renders a single row partial, used by HTMX endpoints.
|
// ItemRow renders a single row partial, used by HTMX endpoints. Callers
|
||||||
func ItemRow(it models.Item, csrf string) templ.Component {
|
// that don't have history cheaply on hand pass nil; the sparkline cell
|
||||||
|
// degrades to an em-dash placeholder.
|
||||||
|
func ItemRow(it models.Item, csrf string, history []models.PricePoint) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
@@ -659,12 +856,12 @@ func ItemRow(it models.Item, csrf string) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var33 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var40 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var33 == nil {
|
if templ_7745c5c3_Var40 == nil {
|
||||||
templ_7745c5c3_Var33 = templ.NopComponent
|
templ_7745c5c3_Var40 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = itemRow(it, csrf).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = itemRow(it, csrf, history).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -689,12 +886,12 @@ func EmptyRow() templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var34 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var41 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var34 == nil {
|
if templ_7745c5c3_Var41 == nil {
|
||||||
templ_7745c5c3_Var34 = templ.NopComponent
|
templ_7745c5c3_Var41 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<tr></tr>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<tr></tr>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,21 +18,25 @@ templ head(title string) {
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<title>{ title } · Veola</title>
|
<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.googleapis.com"/>
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
<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 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"/>
|
<link rel="stylesheet" href="/static/css/app.css"/>
|
||||||
|
<!-- retro.css is an additive flair layer. Delete this <link> + the
|
||||||
|
file to revert to the plainer look. See retro.css for details. -->
|
||||||
|
<link rel="stylesheet" href="/static/css/retro.css"/>
|
||||||
<script src="/static/vendor/htmx.min.js" defer></script>
|
<script src="/static/vendor/htmx.min.js" defer></script>
|
||||||
|
<script src="/static/js/flair.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ Sidebar(active string) {
|
templ Sidebar(active string) {
|
||||||
<nav class="v-side-nav flex flex-col">
|
<nav class="v-side-nav flex flex-col">
|
||||||
<div class="px-4 py-5 flex items-center gap-2">
|
<a href="/" class="v-side-brand px-4 py-5 flex items-center gap-2">
|
||||||
<span class="text-2xl">🐝</span>
|
<span class="text-2xl">🐝</span>
|
||||||
<span class="text-xl font-semibold tracking-wide">Veola</span>
|
<span class="text-xl font-semibold tracking-wide">Veola</span>
|
||||||
</div>
|
</a>
|
||||||
<a href="/" class={ navClass("dashboard", active) }>Dashboard</a>
|
<a href="/" class={ navClass("dashboard", active) }>Dashboard</a>
|
||||||
<a href="/items" class={ navClass("items", active) }>Items</a>
|
<a href="/items" class={ navClass("items", active) }>Items</a>
|
||||||
<a href="/results" class={ navClass("results", active) }>Results</a>
|
<a href="/results" class={ navClass("results", active) }>Results</a>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func head(title string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Veola</title><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\"><!-- retro.css is an additive flair layer. Delete this <link> + the\n\t\t file to revert to the plainer look. See retro.css for details. --><link rel=\"stylesheet\" href=\"/static/css/retro.css\"><script src=\"/static/vendor/htmx.min.js\" defer></script><script src=\"/static/js/flair.js\" defer></script></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ func Sidebar(active string) templ.Component {
|
|||||||
templ_7745c5c3_Var3 = templ.NopComponent
|
templ_7745c5c3_Var3 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<nav class=\"v-side-nav flex flex-col\"><div class=\"px-4 py-5 flex items-center gap-2\"><span class=\"text-2xl\">🐝</span> <span class=\"text-xl font-semibold tracking-wide\">Veola</span></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<nav class=\"v-side-nav flex flex-col\"><a href=\"/\" class=\"v-side-brand px-4 py-5 flex items-center gap-2\"><span class=\"text-2xl\">🐝</span> <span class=\"text-xl font-semibold tracking-wide\">Veola</span></a> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -236,7 +236,7 @@ func Layout(p Page, body templ.Component) templ.Component {
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(p.Flash)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(p.Flash)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 61, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 65, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -255,7 +255,7 @@ func Layout(p Page, body templ.Component) templ.Component {
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.FlashError)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.FlashError)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 64, Col: 46}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 68, Col: 46}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -353,7 +353,7 @@ func CSRFInput(token string) templ.Component {
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(token)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(token)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 85, Col: 53}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 89, Col: 53}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -7,35 +7,40 @@ type LoginData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ loginBody(d LoginData) {
|
templ loginBody(d LoginData) {
|
||||||
<div class="min-h-screen grid md:grid-cols-2">
|
<div class="min-h-screen grid md:grid-cols-2 v-auth-grid">
|
||||||
<div class="flex items-center justify-center p-8">
|
<div class="flex items-center justify-center p-8">
|
||||||
<div class="w-full max-w-sm">
|
<div class="w-full max-w-sm">
|
||||||
<div class="flex items-center gap-2 mb-6">
|
<div class="flex items-center gap-2 mb-8 v-auth-wordmark">
|
||||||
<span class="text-3xl">🐝</span>
|
<span class="text-3xl">🐝</span>
|
||||||
<span class="text-2xl font-semibold tracking-wide">Veola</span>
|
<span class="text-2xl font-semibold tracking-wide">Veola</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl font-semibold mb-2">Open the door.</h1>
|
<div class="v-card v-auth-card p-7">
|
||||||
<p class="v-muted mb-6">Sign in to continue.</p>
|
<h1 class="text-3xl font-semibold mb-2">Open the door.</h1>
|
||||||
if d.Error != "" {
|
<p class="v-muted mb-6">Sign in to continue.</p>
|
||||||
<div class="v-flash-error">{ d.Error }</div>
|
if d.Error != "" {
|
||||||
}
|
<div class="v-flash-error">{ d.Error }</div>
|
||||||
<form method="post" action="/login" class="space-y-4">
|
}
|
||||||
@CSRFInput(d.CSRFToken)
|
<form method="post" action="/login" class="space-y-4">
|
||||||
<div>
|
@CSRFInput(d.CSRFToken)
|
||||||
<label class="v-label">Username</label>
|
<div>
|
||||||
<input class="v-input" name="username" autocomplete="username" autofocus value={ d.Username }/>
|
<label class="v-label">Username</label>
|
||||||
</div>
|
<input class="v-input" name="username" autocomplete="username" autofocus value={ d.Username }/>
|
||||||
<div>
|
</div>
|
||||||
<label class="v-label">Password</label>
|
<div>
|
||||||
<input class="v-input" type="password" name="password" autocomplete="current-password"/>
|
<label class="v-label">Password</label>
|
||||||
</div>
|
<input class="v-input" type="password" name="password" autocomplete="current-password"/>
|
||||||
<button class="v-btn w-full justify-center" type="submit">Sign In</button>
|
</div>
|
||||||
</form>
|
<button class="v-btn w-full justify-center" type="submit">Sign In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p class="v-auth-tagline">Track · Watch · Notice</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden md:flex items-end justify-center p-8 bg-[#152560]">
|
<div class="hidden md:flex flex-col items-center justify-center p-8 v-auth-portrait-col">
|
||||||
<div class="v-veola-portrait max-w-md">
|
<div class="v-auth-portrait-halo">
|
||||||
<img src="/static/img/veola.webp" alt="Veola"/>
|
<div class="v-veola-portrait max-w-md">
|
||||||
|
<img src="/static/img/veola.avif" alt="Veola"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,39 +57,44 @@ type SetupData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ setupBody(d SetupData) {
|
templ setupBody(d SetupData) {
|
||||||
<div class="min-h-screen grid md:grid-cols-2">
|
<div class="min-h-screen grid md:grid-cols-2 v-auth-grid">
|
||||||
<div class="flex items-center justify-center p-8">
|
<div class="flex items-center justify-center p-8">
|
||||||
<div class="w-full max-w-sm">
|
<div class="w-full max-w-sm">
|
||||||
<div class="flex items-center gap-2 mb-6">
|
<div class="flex items-center gap-2 mb-8 v-auth-wordmark">
|
||||||
<span class="text-3xl">🐝</span>
|
<span class="text-3xl">🐝</span>
|
||||||
<span class="text-2xl font-semibold tracking-wide">Veola</span>
|
<span class="text-2xl font-semibold tracking-wide">Veola</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl font-semibold mb-2">First time here.</h1>
|
<div class="v-card v-auth-card p-7">
|
||||||
<p class="v-muted mb-6">Create the admin account. Password must be at least 12 characters.</p>
|
<h1 class="text-3xl font-semibold mb-2">First time here.</h1>
|
||||||
if d.Error != "" {
|
<p class="v-muted mb-6">Create the admin account. Password must be at least 12 characters.</p>
|
||||||
<div class="v-flash-error">{ d.Error }</div>
|
if d.Error != "" {
|
||||||
}
|
<div class="v-flash-error">{ d.Error }</div>
|
||||||
<form method="post" action="/setup" class="space-y-4">
|
}
|
||||||
@CSRFInput(d.CSRFToken)
|
<form method="post" action="/setup" class="space-y-4">
|
||||||
<div>
|
@CSRFInput(d.CSRFToken)
|
||||||
<label class="v-label">Username</label>
|
<div>
|
||||||
<input class="v-input" name="username" autofocus value={ d.Username }/>
|
<label class="v-label">Username</label>
|
||||||
</div>
|
<input class="v-input" name="username" autofocus value={ d.Username }/>
|
||||||
<div>
|
</div>
|
||||||
<label class="v-label">Password</label>
|
<div>
|
||||||
<input class="v-input" type="password" name="password"/>
|
<label class="v-label">Password</label>
|
||||||
</div>
|
<input class="v-input" type="password" name="password"/>
|
||||||
<div>
|
</div>
|
||||||
<label class="v-label">Confirm Password</label>
|
<div>
|
||||||
<input class="v-input" type="password" name="password_confirm"/>
|
<label class="v-label">Confirm Password</label>
|
||||||
</div>
|
<input class="v-input" type="password" name="password_confirm"/>
|
||||||
<button class="v-btn w-full justify-center" type="submit">Create Admin</button>
|
</div>
|
||||||
</form>
|
<button class="v-btn w-full justify-center" type="submit">Create Admin</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p class="v-auth-tagline">Track · Watch · Notice</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden md:flex items-end justify-center p-8 bg-[#152560]">
|
<div class="hidden md:flex flex-col items-center justify-center p-8 v-auth-portrait-col">
|
||||||
<div class="v-veola-portrait max-w-md">
|
<div class="v-auth-portrait-halo">
|
||||||
<img src="/static/img/veola.webp" alt="Veola"/>
|
<div class="v-veola-portrait max-w-md">
|
||||||
|
<img src="/static/img/veola.avif" alt="Veola"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func loginBody(d LoginData) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"min-h-screen grid md:grid-cols-2\"><div class=\"flex items-center justify-center p-8\"><div class=\"w-full max-w-sm\"><div class=\"flex items-center gap-2 mb-6\"><span class=\"text-3xl\">🐝</span> <span class=\"text-2xl font-semibold tracking-wide\">Veola</span></div><h1 class=\"text-3xl font-semibold mb-2\">Open the door.</h1><p class=\"v-muted mb-6\">Sign in to continue.</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"min-h-screen grid md:grid-cols-2 v-auth-grid\"><div class=\"flex items-center justify-center p-8\"><div class=\"w-full max-w-sm\"><div class=\"flex items-center gap-2 mb-8 v-auth-wordmark\"><span class=\"text-3xl\">🐝</span> <span class=\"text-2xl font-semibold tracking-wide\">Veola</span></div><div class=\"v-card v-auth-card p-7\"><h1 class=\"text-3xl font-semibold mb-2\">Open the door.</h1><p class=\"v-muted mb-6\">Sign in to continue.</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ func loginBody(d LoginData) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 20, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 21, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -73,13 +73,13 @@ func loginBody(d LoginData) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Username)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 26, Col: 97}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 27, Col: 98}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"></div><div><label class=\"v-label\">Password</label> <input class=\"v-input\" type=\"password\" name=\"password\" autocomplete=\"current-password\"></div><button class=\"v-btn w-full justify-center\" type=\"submit\">Sign In</button></form></div></div><div class=\"hidden md:flex items-end justify-center p-8 bg-[#152560]\"><div class=\"v-veola-portrait max-w-md\"><img src=\"/static/img/veola.webp\" alt=\"Veola\"></div></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"></div><div><label class=\"v-label\">Password</label> <input class=\"v-input\" type=\"password\" name=\"password\" autocomplete=\"current-password\"></div><button class=\"v-btn w-full justify-center\" type=\"submit\">Sign In</button></form></div><p class=\"v-auth-tagline\">Track · Watch · Notice</p></div></div><div class=\"hidden md:flex flex-col items-center justify-center p-8 v-auth-portrait-col\"><div class=\"v-auth-portrait-halo\"><div class=\"v-veola-portrait max-w-md\"><img src=\"/static/img/veola.avif\" alt=\"Veola\"></div></div></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ func setupBody(d SetupData) templ.Component {
|
|||||||
templ_7745c5c3_Var5 = templ.NopComponent
|
templ_7745c5c3_Var5 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"min-h-screen grid md:grid-cols-2\"><div class=\"flex items-center justify-center p-8\"><div class=\"w-full max-w-sm\"><div class=\"flex items-center gap-2 mb-6\"><span class=\"text-3xl\">🐝</span> <span class=\"text-2xl font-semibold tracking-wide\">Veola</span></div><h1 class=\"text-3xl font-semibold mb-2\">First time here.</h1><p class=\"v-muted mb-6\">Create the admin account. Password must be at least 12 characters.</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"min-h-screen grid md:grid-cols-2 v-auth-grid\"><div class=\"flex items-center justify-center p-8\"><div class=\"w-full max-w-sm\"><div class=\"flex items-center gap-2 mb-8 v-auth-wordmark\"><span class=\"text-3xl\">🐝</span> <span class=\"text-2xl font-semibold tracking-wide\">Veola</span></div><div class=\"v-card v-auth-card p-7\"><h1 class=\"text-3xl font-semibold mb-2\">First time here.</h1><p class=\"v-muted mb-6\">Create the admin account. Password must be at least 12 characters.</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ func setupBody(d SetupData) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 65, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 71, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -181,13 +181,13 @@ func setupBody(d SetupData) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Username)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 71, Col: 73}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 77, Col: 74}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"></div><div><label class=\"v-label\">Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><div><label class=\"v-label\">Confirm Password</label> <input class=\"v-input\" type=\"password\" name=\"password_confirm\"></div><button class=\"v-btn w-full justify-center\" type=\"submit\">Create Admin</button></form></div></div><div class=\"hidden md:flex items-end justify-center p-8 bg-[#152560]\"><div class=\"v-veola-portrait max-w-md\"><img src=\"/static/img/veola.webp\" alt=\"Veola\"></div></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"></div><div><label class=\"v-label\">Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><div><label class=\"v-label\">Confirm Password</label> <input class=\"v-input\" type=\"password\" name=\"password_confirm\"></div><button class=\"v-btn w-full justify-center\" type=\"submit\">Create Admin</button></form></div><p class=\"v-auth-tagline\">Track · Watch · Notice</p></div></div><div class=\"hidden md:flex flex-col items-center justify-center p-8 v-auth-portrait-col\"><div class=\"v-auth-portrait-halo\"><div class=\"v-veola-portrait max-w-md\"><img src=\"/static/img/veola.avif\" alt=\"Veola\"></div></div></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package templates
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"veola/internal/db"
|
||||||
"veola/internal/models"
|
"veola/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +19,13 @@ type ItemResultsData struct {
|
|||||||
TotalPages int
|
TotalPages int
|
||||||
Order string
|
Order string
|
||||||
HistoryChartJSON 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
|
||||||
|
// EndingSoon, when non-nil, surfaces the soonest-ending auction across this
|
||||||
|
// item's results so users can act before close.
|
||||||
|
EndingSoon *db.EndingSoon
|
||||||
}
|
}
|
||||||
|
|
||||||
type BadgeData struct {
|
type BadgeData struct {
|
||||||
@@ -31,6 +40,8 @@ type GlobalResultsData struct {
|
|||||||
ItemID int64
|
ItemID int64
|
||||||
From string
|
From string
|
||||||
To string
|
To string
|
||||||
|
// EndingSoon mirrors the per-item field but spans every watched item.
|
||||||
|
EndingSoon *db.EndingSoon
|
||||||
}
|
}
|
||||||
|
|
||||||
type ItemResultRow struct {
|
type ItemResultRow struct {
|
||||||
@@ -38,8 +49,40 @@ type ItemResultRow struct {
|
|||||||
ItemName string
|
ItemName string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// endingSoonStrip surfaces the next auction about to close. Hidden when nil
|
||||||
|
// (the handler decides cutoff: 24h by default). The data-ends-at attribute
|
||||||
|
// drives the live countdown in flair.js.
|
||||||
|
templ endingSoonStrip(e *db.EndingSoon) {
|
||||||
|
if e != nil {
|
||||||
|
<div class="v-ending-strip mb-5" data-ends-at={ e.EndsAt.UTC().Format(time.RFC3339) }>
|
||||||
|
<div class="v-ending-label">Ending soon</div>
|
||||||
|
<div class="v-ending-title">
|
||||||
|
if e.URL != "" {
|
||||||
|
<a href={ templ.SafeURL(e.URL) } target="_blank" rel="noopener">{ e.Title }</a>
|
||||||
|
} else {
|
||||||
|
{ e.Title }
|
||||||
|
}
|
||||||
|
<span class="v-muted text-xs ml-2">{ e.ItemName }</span>
|
||||||
|
</div>
|
||||||
|
<div class="v-ending-countdown font-mono" data-countdown></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// endsInCell renders the per-row countdown for auction listings only. Fixed-
|
||||||
|
// price rows (EndsAt == nil) get an em-dash placeholder so column widths stay
|
||||||
|
// consistent.
|
||||||
|
templ endsInCell(endsAt *time.Time) {
|
||||||
|
if endsAt != nil {
|
||||||
|
<span class="v-countdown font-mono text-sm" data-ends-at={ endsAt.UTC().Format(time.RFC3339) } data-countdown></span>
|
||||||
|
} else {
|
||||||
|
<span class="v-muted">—</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
templ itemResultsBody(d ItemResultsData) {
|
templ itemResultsBody(d ItemResultsData) {
|
||||||
<div>
|
<div>
|
||||||
|
@endingSoonStrip(d.EndingSoon)
|
||||||
<div class="flex items-start justify-between mb-4">
|
<div class="flex items-start justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-semibold">{ d.Item.Name }</h1>
|
<h1 class="text-3xl font-semibold">{ d.Item.Name }</h1>
|
||||||
@@ -49,7 +92,7 @@ templ itemResultsBody(d ItemResultsData) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
if d.Item.BestPrice != nil {
|
if d.Item.BestPrice != nil {
|
||||||
<div class="font-mono text-3xl">{ fmtPrice(d.Item.BestPrice, "USD") }</div>
|
<div class="font-mono text-3xl">{ fmtPrice(d.Item.BestPrice, d.Item.BestPriceCurrency) }</div>
|
||||||
if d.Item.BestPriceURL != "" {
|
if d.Item.BestPriceURL != "" {
|
||||||
<a class="text-sm" href={ templ.SafeURL(d.Item.BestPriceURL) } target="_blank" rel="noopener">{ d.Item.BestPriceStore }</a>
|
<a class="text-sm" href={ templ.SafeURL(d.Item.BestPriceURL) } target="_blank" rel="noopener">{ d.Item.BestPriceStore }</a>
|
||||||
}
|
}
|
||||||
@@ -61,8 +104,17 @@ templ itemResultsBody(d ItemResultsData) {
|
|||||||
<span class={ "v-badge", d.Badge.Class }>{ d.Badge.Label }</span>
|
<span class={ "v-badge", d.Badge.Class }>{ d.Badge.Label }</span>
|
||||||
</div>
|
</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="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>
|
<button class="v-btn" type="submit">Run Now</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,41 +125,32 @@ templ itemResultsBody(d ItemResultsData) {
|
|||||||
if len(d.History) < 2 {
|
if len(d.History) < 2 {
|
||||||
<div class="v-muted">Not enough history yet.</div>
|
<div class="v-muted">Not enough history yet.</div>
|
||||||
} else {
|
} 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.
|
||||||
|
<div class="relative h-64 w-full">
|
||||||
|
<canvas id="price-chart" data-chart={ d.HistoryChartJSON }></canvas>
|
||||||
|
</div>
|
||||||
<script src="/static/vendor/chart.umd.min.js"></script>
|
<script src="/static/vendor/chart.umd.min.js"></script>
|
||||||
<script id="price-data" type="application/json">{ d.HistoryChartJSON }</script>
|
<script src="/static/js/price-chart.js"></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>
|
|
||||||
}
|
}
|
||||||
</div>
|
</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">
|
<div class="v-card p-0 overflow-hidden">
|
||||||
<table class="v-table">
|
<table class="v-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -118,6 +161,7 @@ templ itemResultsBody(d ItemResultsData) {
|
|||||||
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "price"))) }>Price</a>
|
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "price"))) }>Price</a>
|
||||||
</th>
|
</th>
|
||||||
<th>Store</th>
|
<th>Store</th>
|
||||||
|
<th>Ends</th>
|
||||||
<th>
|
<th>
|
||||||
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "found"))) }>Found</a>
|
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results?order=%s", d.Item.ID, toggleOrder(d.Order, "found"))) }>Found</a>
|
||||||
</th>
|
</th>
|
||||||
@@ -144,6 +188,7 @@ templ itemResultsBody(d ItemResultsData) {
|
|||||||
</td>
|
</td>
|
||||||
<td class={ "font-mono", priceClass(r.Price, d.Item.TargetPrice) }>{ fmtPrice(r.Price, r.Currency) }</td>
|
<td class={ "font-mono", priceClass(r.Price, d.Item.TargetPrice) }>{ fmtPrice(r.Price, r.Currency) }</td>
|
||||||
<td class="v-muted">{ r.Source }</td>
|
<td class="v-muted">{ r.Source }</td>
|
||||||
|
<td>@endsInCell(r.EndsAt)</td>
|
||||||
<td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td>
|
<td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td>
|
||||||
<td>
|
<td>
|
||||||
if r.Alerted {
|
if r.Alerted {
|
||||||
@@ -195,6 +240,7 @@ templ ItemResults(d ItemResultsData) {
|
|||||||
templ globalResultsBody(d GlobalResultsData) {
|
templ globalResultsBody(d GlobalResultsData) {
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-semibold mb-6">All Results</h1>
|
<h1 class="text-3xl font-semibold mb-6">All Results</h1>
|
||||||
|
@endingSoonStrip(d.EndingSoon)
|
||||||
<form method="get" action="/results" class="flex items-end gap-3 mb-4">
|
<form method="get" action="/results" class="flex items-end gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="v-label">Item</label>
|
<label class="v-label">Item</label>
|
||||||
@@ -218,7 +264,7 @@ templ globalResultsBody(d GlobalResultsData) {
|
|||||||
<div class="v-card p-0 overflow-hidden">
|
<div class="v-card p-0 overflow-hidden">
|
||||||
<table class="v-table">
|
<table class="v-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Item</th><th>Title</th><th>Price</th><th>Store</th><th>Found</th><th>Alert</th></tr>
|
<tr><th>Item</th><th>Title</th><th>Price</th><th>Store</th><th>Ends</th><th>Found</th><th>Alert</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
for _, r := range d.Results {
|
for _, r := range d.Results {
|
||||||
@@ -236,6 +282,7 @@ templ globalResultsBody(d GlobalResultsData) {
|
|||||||
</td>
|
</td>
|
||||||
<td class="font-mono">{ fmtPrice(r.Price, r.Currency) }</td>
|
<td class="font-mono">{ fmtPrice(r.Price, r.Currency) }</td>
|
||||||
<td class="v-muted">{ r.Source }</td>
|
<td class="v-muted">{ r.Source }</td>
|
||||||
|
<td>@endsInCell(r.EndsAt)</td>
|
||||||
<td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td>
|
<td class="v-muted text-sm">{ humanTime(r.FoundAt) }</td>
|
||||||
<td>
|
<td>
|
||||||
if r.Alerted {
|
if r.Alerted {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,15 +8,38 @@ import (
|
|||||||
|
|
||||||
type SettingsData struct {
|
type SettingsData struct {
|
||||||
Page
|
Page
|
||||||
Values map[string]string
|
Values map[string]string
|
||||||
IsAdmin bool
|
// CredentialStatus maps each secret settings key to a human-readable
|
||||||
Users []models.User
|
// status ("Saved in settings", "Set in config.toml", "Not set"). Secret
|
||||||
TestNtfyOK string
|
// values are never rendered into the form itself.
|
||||||
TestApifyOK string
|
CredentialStatus map[string]string
|
||||||
PasswordMsg string
|
IsAdmin bool
|
||||||
PasswordError string
|
Users []models.User
|
||||||
UserMsg string
|
TestNtfyOK string
|
||||||
UserError 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// credStatus renders the "Saved in settings / Set in config.toml / Not set"
|
||||||
|
// indicator for a secret field without ever printing the secret itself.
|
||||||
|
templ credStatus(d SettingsData, key string) {
|
||||||
|
if s := d.CredentialStatus[key]; s != "" {
|
||||||
|
<div class="v-muted text-xs mt-1">
|
||||||
|
Status: { s }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ settingsBody(d SettingsData) {
|
templ settingsBody(d SettingsData) {
|
||||||
@@ -24,14 +47,40 @@ templ settingsBody(d SettingsData) {
|
|||||||
<h1 class="text-3xl font-semibold">Settings</h1>
|
<h1 class="text-3xl font-semibold">Settings</h1>
|
||||||
|
|
||||||
<section class="v-card p-6">
|
<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">
|
<form method="post" action="/settings" class="space-y-4">
|
||||||
@CSRFInput(d.CSRFToken)
|
@CSRFInput(d.CSRFToken)
|
||||||
<div>
|
<div>
|
||||||
<label class="v-label">Apify API Key</label>
|
<label class="v-label">Apify API Key</label>
|
||||||
<input class="v-input font-mono" type="password" name="apify_api_key" value={ d.Values["apify_api_key"] }/>
|
<input class="v-input font-mono" type="password" name="apify_api_key" autocomplete="off" placeholder="leave blank to keep current value"/>
|
||||||
|
@credStatus(d, "apify_api_key")
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-white/10 pt-4">
|
||||||
|
<label class="v-label">eBay App ID (Client ID)</label>
|
||||||
|
<input class="v-input font-mono" type="password" name="ebay_client_id" autocomplete="off" placeholder="used for eBay marketplaces instead of Apify"/>
|
||||||
|
@credStatus(d, "ebay_client_id")
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<label class="v-label">eBay Cert ID (Client Secret)</label>
|
||||||
|
<input class="v-input font-mono" type="password" name="ebay_client_secret" autocomplete="off" placeholder="leave blank to keep current value"/>
|
||||||
|
@credStatus(d, "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>
|
<label class="v-label">Ntfy Base URL</label>
|
||||||
<input class="v-input" name="ntfy_base_url" value={ d.Values["ntfy_base_url"] }/>
|
<input class="v-input" name="ntfy_base_url" value={ d.Values["ntfy_base_url"] }/>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +90,8 @@ templ settingsBody(d SettingsData) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="v-label">Ntfy Token</label>
|
<label class="v-label">Ntfy Token</label>
|
||||||
<input class="v-input font-mono" type="password" name="ntfy_token" value={ d.Values["ntfy_token"] } placeholder="tk_... (leave blank if ntfy is unauthenticated)"/>
|
<input class="v-input font-mono" type="password" name="ntfy_token" autocomplete="off" placeholder="tk_... (leave blank to keep current value)"/>
|
||||||
|
@credStatus(d, "ntfy_token")
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="v-label">Global Poll Interval (minutes)</label>
|
<label class="v-label">Global Poll Interval (minutes)</label>
|
||||||
@@ -58,6 +108,7 @@ templ settingsBody(d SettingsData) {
|
|||||||
<button class="v-btn" type="submit">Save</button>
|
<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-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-apify">Test Apify</button>
|
||||||
|
<button class="v-btn-ghost" type="submit" formaction="/settings/test-ebay">Test eBay</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</form>
|
</form>
|
||||||
@@ -67,6 +118,9 @@ templ settingsBody(d SettingsData) {
|
|||||||
if d.TestApifyOK != "" {
|
if d.TestApifyOK != "" {
|
||||||
<div class="v-flash mt-3">{ d.TestApifyOK }</div>
|
<div class="v-flash mt-3">{ d.TestApifyOK }</div>
|
||||||
}
|
}
|
||||||
|
if d.TestEbayOK != "" {
|
||||||
|
<div class="v-flash mt-3">{ d.TestEbayOK }</div>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="v-card p-6">
|
<section class="v-card p-6">
|
||||||
|
|||||||
@@ -16,18 +16,33 @@ import (
|
|||||||
|
|
||||||
type SettingsData struct {
|
type SettingsData struct {
|
||||||
Page
|
Page
|
||||||
Values map[string]string
|
Values map[string]string
|
||||||
IsAdmin bool
|
// CredentialStatus maps each secret settings key to a human-readable
|
||||||
Users []models.User
|
// status ("Saved in settings", "Set in config.toml", "Not set"). Secret
|
||||||
TestNtfyOK string
|
// values are never rendered into the form itself.
|
||||||
TestApifyOK string
|
CredentialStatus map[string]string
|
||||||
PasswordMsg string
|
IsAdmin bool
|
||||||
PasswordError string
|
Users []models.User
|
||||||
UserMsg string
|
TestNtfyOK string
|
||||||
UserError string
|
TestApifyOK string
|
||||||
|
TestEbayOK string
|
||||||
|
EbayUsedToday int
|
||||||
|
EbayDailyLimit int
|
||||||
|
PasswordMsg string
|
||||||
|
PasswordError string
|
||||||
|
UserMsg string
|
||||||
|
UserError string
|
||||||
}
|
}
|
||||||
|
|
||||||
func settingsBody(d SettingsData) templ.Component {
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// credStatus renders the "Saved in settings / Set in config.toml / Not set"
|
||||||
|
// indicator for a secret field without ever printing the secret itself.
|
||||||
|
func credStatus(d SettingsData, key string) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
@@ -48,7 +63,51 @@ func settingsBody(d SettingsData) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"space-y-8 max-w-3xl\"><h1 class=\"text-3xl font-semibold\">Settings</h1><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Apify and Ntfy</h2><form method=\"post\" action=\"/settings\" class=\"space-y-4\">")
|
if s := d.CredentialStatus[key]; s != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"v-muted text-xs mt-1\">Status: ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var2 string
|
||||||
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(s)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 40, Col: 14}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsBody(d SettingsData) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var3 == nil {
|
||||||
|
templ_7745c5c3_Var3 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"space-y-8 max-w-3xl\"><h1 class=\"text-3xl font-semibold\">Settings</h1><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Apify, eBay and Ntfy</h2><form method=\"post\" action=\"/settings\" class=\"space-y-4\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -56,184 +115,269 @@ func settingsBody(d SettingsData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div><label class=\"v-label\">Apify API Key</label> <input class=\"v-input font-mono\" type=\"password\" name=\"apify_api_key\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div><label class=\"v-label\">Apify API Key</label> <input class=\"v-input font-mono\" type=\"password\" name=\"apify_api_key\" autocomplete=\"off\" placeholder=\"leave blank to keep current value\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
templ_7745c5c3_Err = credStatus(d, "apify_api_key").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["apify_api_key"])
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 32, Col: 108}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"></div><div><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, 5, "</div><div class=\"border-t border-white/10 pt-4\"><label class=\"v-label\">eBay App ID (Client ID)</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ebay_client_id\" autocomplete=\"off\" placeholder=\"used for eBay marketplaces instead of Apify\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
templ_7745c5c3_Err = credStatus(d, "ebay_client_id").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_base_url"])
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 36, Col: 82}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"></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, 6, "</div><div><label class=\"v-label\">eBay Cert ID (Client Secret)</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ebay_client_secret\" autocomplete=\"off\" placeholder=\"leave blank to keep current value\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = credStatus(d, "ebay_client_secret").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div><label class=\"v-label\">eBay Daily Call Limit</label> <input class=\"v-input font-mono\" name=\"ebay_daily_call_limit\" type=\"number\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_default_topic"])
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ebay_daily_call_limit"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 40, Col: 92}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 70, Col: 122}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"></div><div><label class=\"v-label\">Ntfy Token</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ntfy_token\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" placeholder=\"5000 (blank uses config default)\"></div><div class=\"text-sm\"><span class=\"v-muted\">eBay API calls today:</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
if d.EbayDailyLimit > 0 {
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_token"])
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"font-mono\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 44, Col: 102}
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 75, Col: 89}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"font-mono\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 string
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d (uncapped)", d.EbayUsedToday))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 77, Col: 77}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
if d.EbayLimitReached() {
|
||||||
if templ_7745c5c3_Err != nil {
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"v-flash-error inline-block ml-2\">Limit reached. eBay polling halted until the next reset (midnight US Pacific).</span>")
|
||||||
return templ_7745c5c3_Err
|
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, 14, "</div><div class=\"border-t border-white/10 pt-4\"><label class=\"v-label\">Ntfy Base URL</label> <input class=\"v-input\" name=\"ntfy_base_url\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var6 string
|
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["global_poll_interval_minutes"])
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 48, Col: 136}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"></div><div><label class=\"v-label\">Match Confidence Threshold</label> <input class=\"v-input font-mono\" name=\"match_confidence_threshold\" type=\"number\" min=\"0\" max=\"1\" step=\"0.05\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["match_confidence_threshold"])
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 52, Col: 160}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 85, Col: 82}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"></div><div><label class=\"v-label\">Ntfy Default Topic</label> <input class=\"v-input\" name=\"ntfy_default_topic\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_default_topic"])
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 89, Col: 92}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"></div><div><label class=\"v-label\">Ntfy Token</label> <input class=\"v-input font-mono\" type=\"password\" name=\"ntfy_token\" autocomplete=\"off\" placeholder=\"tk_... (leave blank to keep current value)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = credStatus(d, "ntfy_token").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div><div><label class=\"v-label\">Global Poll Interval (minutes)</label> <input class=\"v-input font-mono\" name=\"global_poll_interval_minutes\" type=\"number\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["global_poll_interval_minutes"])
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 98, Col: 136}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\"></div><div><label class=\"v-label\">Match Confidence Threshold</label> <input class=\"v-input font-mono\" name=\"match_confidence_threshold\" type=\"number\" min=\"0\" max=\"1\" step=\"0.05\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["match_confidence_threshold"])
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 102, Col: 160}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if !d.IsAdmin {
|
if !d.IsAdmin {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"v-muted text-sm\">Read-only for non-admin users.</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"v-muted text-sm\">Read-only for non-admin users.</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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, 21, "<div class=\"flex items-center gap-3 pt-1\"><button class=\"v-btn\" type=\"submit\">Save</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ntfy\">Test Ntfy</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-apify\">Test Apify</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ebay\">Test eBay</button></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</form>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</form>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if d.TestNtfyOK != "" {
|
if d.TestNtfyOK != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<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_Var8 string
|
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 65, Col: 44}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</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\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var9 string
|
|
||||||
templ_7745c5c3_Var9, 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}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</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 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\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var10 string
|
|
||||||
templ_7745c5c3_Var10, 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}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</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\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordMsg)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 78, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 116, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<form method=\"post\" action=\"/settings/password\" class=\"space-y-4\">")
|
if d.TestApifyOK != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"v-flash mt-3\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 119, Col: 45}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.TestEbayOK != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"v-flash mt-3\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestEbayOK)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 122, Col: 44}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</section><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Change Password</h2>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if d.PasswordError != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"v-flash-error\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 string
|
||||||
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 129, Col: 48}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.PasswordMsg != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"v-flash\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordMsg)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 132, Col: 40}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<form method=\"post\" action=\"/settings/password\" class=\"space-y-4\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -241,173 +385,173 @@ func settingsBody(d SettingsData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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, 35, "<div><label class=\"v-label\">Current Password</label> <input class=\"v-input\" type=\"password\" name=\"current_password\"></div><div><label class=\"v-label\">New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password\"></div><div><label class=\"v-label\">Confirm New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password_confirm\"></div><button class=\"v-btn\" type=\"submit\">Update Password</button></form></section>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if d.IsAdmin {
|
if d.IsAdmin {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Users</h2>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Users</h2>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if d.UserError != "" {
|
if d.UserError != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"v-flash-error\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"v-flash-error\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var12 string
|
|
||||||
templ_7745c5c3_Var12, 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}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</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\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var13 string
|
|
||||||
templ_7745c5c3_Var13, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02"))
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 114, Col: 70}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 156, Col: 45}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</td><td class=\"text-right\"><form class=\"inline\" method=\"post\" action=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var17 templ.SafeURL
|
}
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/reset-password", u.ID)))
|
if d.UserMsg != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"v-flash\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 116, Col: 113}
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var17 string
|
||||||
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 159, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<table class=\"v-table mb-4\"><thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead> <tbody>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, u := range d.Users {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<tr><td>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 117, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 166, Col: 24}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</td><td class=\"v-muted\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var19 templ.SafeURL
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/delete", u.ID)))
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role))
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 167, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" onsubmit=\"return confirm('Remove user?')\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</td><td class=\"v-muted text-sm\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02"))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 122, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 168, Col: 70}
|
||||||
}
|
}
|
||||||
_, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"> <button class=\"v-btn-ghost\" type=\"submit\">Remove</button></form></td></tr>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</td><td class=\"text-right\"><form class=\"inline\" method=\"post\" action=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var21 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/reset-password", u.ID)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 170, Col: 113}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var22 string
|
||||||
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 171, Col: 68}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\"> <input type=\"password\" class=\"v-input inline-block max-w-[140px]\" name=\"new_password\" placeholder=\"new password\"> <button class=\"v-btn-ghost\" type=\"submit\">Reset</button></form><form class=\"inline\" method=\"post\" action=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var23 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/delete", u.ID)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 175, Col: 105}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" onsubmit=\"return confirm('Remove user?')\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var24 string
|
||||||
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 176, Col: 68}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"> <button class=\"v-btn-ghost\" type=\"submit\">Remove</button></form></td></tr>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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, 50, "</tbody></table><form method=\"post\" action=\"/users\" class=\"grid md:grid-cols-4 gap-3 items-end\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 131, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 185, Col: 63}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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, 51, "\"><div><label class=\"v-label\">Username</label> <input class=\"v-input\" name=\"username\"></div><div><label class=\"v-label\">Role</label> <select class=\"v-select\" name=\"role\"><option value=\"user\">user</option> <option value=\"admin\">admin</option></select></div><div><label class=\"v-label\">Initial Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><button class=\"v-btn\" type=\"submit\">Add User</button></form></section>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -431,9 +575,9 @@ func Settings(d SettingsData) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var22 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var26 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var22 == nil {
|
if templ_7745c5c3_Var26 == nil {
|
||||||
templ_7745c5c3_Var22 = templ.NopComponent
|
templ_7745c5c3_Var26 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
|||||||
Reference in New Issue
Block a user